diff --git a/UltiMaker-Cura.spec.jinja b/UltiMaker-Cura.spec.jinja index 3d540d3b8f..cd939cf736 100644 --- a/UltiMaker-Cura.spec.jinja +++ b/UltiMaker-Cura.spec.jinja @@ -266,6 +266,10 @@ app = UMBUNDLE( 'CFBundlePackageType': 'APPL', 'CFBundleVersionString': {{ version }}, 'CFBundleShortVersionString': {{ short_version }}, + 'CFBundleURLTypes': [{ + 'CFBundleURLName': '{{ display_name }}', + 'CFBundleURLSchemes': ['cura', 'slicer'], + }], 'CFBundleDocumentTypes': [{ 'CFBundleTypeRole': 'Viewer', 'CFBundleTypeExtensions': ['*'], diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 28dfd5e1c9..52f38d4685 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -2,15 +2,18 @@ # Cura is released under the terms of the LGPLv3 or higher. import enum import os +import re import sys import tempfile import time import platform from pathlib import Path from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict +import requests import numpy -from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication +from PyQt6.QtCore import QObject, QTimer, QUrl, QUrlQuery, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication, \ + QByteArray from PyQt6.QtGui import QColor, QIcon from PyQt6.QtQml import qmlRegisterUncreatableType, qmlRegisterUncreatableMetaObject, qmlRegisterSingletonType, qmlRegisterType from PyQt6.QtWidgets import QMessageBox @@ -250,7 +253,7 @@ class CuraApplication(QtApplication): self._additional_components = {} # Components to add to certain areas in the interface self._open_file_queue = [] # A list of files to open (after the application has started) - + self._open_url_queue = [] # A list of urls to open (after the application has started) self._update_platform_activity_timer = None self._sidebar_custom_menu_items = [] # type: list # Keeps list of custom menu items for the side bar @@ -274,6 +277,8 @@ class CuraApplication(QtApplication): self._conan_installs = ApplicationMetadata.CONAN_INSTALLS self._python_installs = ApplicationMetadata.PYTHON_INSTALLS + self._supported_url_schemes: List[str] = ["cura", "slicer"] + @pyqtProperty(str, constant=True) def ultimakerCloudApiRootUrl(self) -> str: return UltimakerCloudConstants.CuraCloudAPIRoot @@ -326,7 +331,11 @@ class CuraApplication(QtApplication): assert not "This crash is triggered by the trigger_early_crash command line argument." for filename in self._cli_args.file: - self._files_to_open.append(os.path.abspath(filename)) + url = QUrl(filename) + if url.scheme() in self._supported_url_schemes: + self._open_url_queue.append(url) + else: + self._files_to_open.append(os.path.abspath(filename)) def initialize(self) -> None: self.__addExpectedResourceDirsAndSearchPaths() # Must be added before init of super @@ -947,6 +956,8 @@ class CuraApplication(QtApplication): self.callLater(self._openFile, file_name) for file_name in self._open_file_queue: # Open all the files that were queued up while plug-ins were loading. self.callLater(self._openFile, file_name) + for url in self._open_url_queue: + self.callLater(self._openUrl, url) initializationFinished = pyqtSignal() showAddPrintersUncancellableDialog = pyqtSignal() # Used to show the add printers dialog with a greyed background @@ -1156,9 +1167,15 @@ class CuraApplication(QtApplication): if event.type() == QEvent.Type.FileOpen: if self._plugins_loaded: - self._openFile(event.file()) + if event.file(): + self._openFile(event.file()) + if event.url(): + self._openUrl(event.url()) else: - self._open_file_queue.append(event.file()) + if event.file(): + self._open_file_queue.append(event.file()) + if event.url(): + self._open_url_queue.append(event.url()) if int(event.type()) == 20: # 'QEvent.Type.Quit' enum isn't there, even though it should be according to docs. # Once we're at this point, everything should have been flushed already (past OnExitCallbackManager). @@ -1542,7 +1559,7 @@ class CuraApplication(QtApplication): if not nodes: return - objects_in_filename = {} # type: Dict[str, List[CuraSceneNode]] + objects_in_filename: Dict[str, List[CuraSceneNode]] = {} for node in nodes: mesh_data = node.getMeshData() if mesh_data: @@ -1783,6 +1800,55 @@ class CuraApplication(QtApplication): def _openFile(self, filename): self.readLocalFile(QUrl.fromLocalFile(filename)) + def _openUrl(self, url: QUrl) -> None: + if url.scheme() not in self._supported_url_schemes: + # only handle cura:// and slicer:// urls schemes + return + + match url.host() + url.path(): + case "open" | "open/": + query = QUrlQuery(url.query()) + model_url = QUrl(query.queryItemValue("file", options=QUrl.ComponentFormattingOption.FullyDecoded)) + + def on_finish(response): + content_disposition_header_key = QByteArray("content-disposition".encode()) + + if not response.hasRawHeader(content_disposition_header_key): + Logger.log("w", "Could not find Content-Disposition header in response from {0}".format( + model_url.url())) + # Use the last part of the url as the filename, and assume it is an STL file + filename = model_url.path().split("/")[-1] + ".stl" + else: + # content_disposition is in the format + # ``` + # content_disposition attachment; "filename=[FILENAME]" + # ``` + # Use a regex to extract the filename + content_disposition = str(response.rawHeader(content_disposition_header_key).data(), + encoding='utf-8') + content_disposition_match = re.match(r'attachment; filename="(?P.*)"', + content_disposition) + assert content_disposition_match is not None + filename = content_disposition_match.group("filename") + + tmp = tempfile.NamedTemporaryFile(suffix=filename, delete=False) + with open(tmp.name, "wb") as f: + f.write(response.readAll()) + + self.readLocalFile(QUrl.fromLocalFile(tmp.name), add_to_recent_files=False) + + def on_error(): + Logger.log("w", "Could not download file from {0}".format(model_url.url())) + return + + self.getHttpRequestManager().get( + model_url.url(), + callback=on_finish, + error_callback=on_error, + ) + case path: + Logger.log("w", "Unsupported url scheme path: {0}".format(path)) + def _addProfileReader(self, profile_reader): # TODO: Add the profile reader to the list of plug-ins that can be used when importing profiles. pass diff --git a/packaging/NSIS/Ultimaker-Cura.nsi.jinja b/packaging/NSIS/Ultimaker-Cura.nsi.jinja index 9996b24773..0a2ce0f517 100644 --- a/packaging/NSIS/Ultimaker-Cura.nsi.jinja +++ b/packaging/NSIS/Ultimaker-Cura.nsi.jinja @@ -144,6 +144,23 @@ SectionEnd ###################################################################### +Section UrlProtocol + +WriteRegStr HKCR "cura" "" "URL:cura" +WriteRegStr HKCR "cura" "URL Protocol" "" +WriteRegStr HKCR "cura\DefaultIcon" "" "$INSTDIR\${MAIN_APP_EXE},1" +WriteRegStr HKCR "cura\shell" "" "open" +WriteRegStr HKCR "cura\shell\open\command" "" '"$INSTDIR\${MAIN_APP_EXE}" "%1"' + +WriteRegStr HKCR "slicer" "" "URL:slicer" +WriteRegStr HKCR "slicer" "URL Protocol" "" +WriteRegStr HKCR "slicer\DefaultIcon" "" "$INSTDIR\${MAIN_APP_EXE},1" +WriteRegStr HKCR "slicer\shell" "" "open" +WriteRegStr HKCR "slicer\shell\open\command" "" '"$INSTDIR\${MAIN_APP_EXE}" "%1"' + +SectionEnd +###################################################################### + Section Uninstall ${INSTALL_TYPE}{% for files in mapped_out_paths.values() %}{% for file in files %} Delete "{{ file[1] }}"{% endfor %}{% endfor %}{% for rem_dir in rmdir_paths %} @@ -187,8 +204,13 @@ RmDir "$SMPROGRAMS\{{ app_name }}" !insertmacro APP_UNASSOCIATE "stl" "Cura.model" !insertmacro APP_UNASSOCIATE "3mf" "Cura.project" +; Unassociate file associations for 'cura' protocol +DeleteRegKey HKCR "cura" + +; Unassociate file associations for 'slicer' protocol +DeleteRegKey HKCR "slicer" + DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}" DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}" SectionEnd -###################################################################### diff --git a/packaging/msi/UltiMaker-Cura.wxs.jinja b/packaging/msi/UltiMaker-Cura.wxs.jinja index a183a97d5f..21f017c813 100644 --- a/packaging/msi/UltiMaker-Cura.wxs.jinja +++ b/packaging/msi/UltiMaker-Cura.wxs.jinja @@ -33,6 +33,21 @@ /> + + + + + {% if "Enterprise" in app_name %} @@ -144,11 +159,32 @@ + + + + + + + + + + + + + + + + + + + + +