From 169a4e02625b13990264ee7a3bff38405c3ce58c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 14 May 2026 14:45:55 +0200 Subject: [PATCH] PICARD-3040: Register a custom protocol handler for browser integration --- appxmanifest.xml.in | 6 +++ installer/picard-setup.nsi.in | 7 ++++ org.musicbrainz.Picard.desktop.in | 6 +-- picard.spec | 8 ++++ picard/__init__.py | 4 +- picard/browser/server.py | 70 ++++++++++++++++++++++++------- picard/remotecommands/handlers.py | 4 ++ picard/tagger.py | 26 +++++++----- setup.py | 3 ++ 9 files changed, 105 insertions(+), 29 deletions(-) diff --git a/appxmanifest.xml.in b/appxmanifest.xml.in index 6860a4121b5..63d3fe11eae 100644 --- a/appxmanifest.xml.in +++ b/appxmanifest.xml.in @@ -108,6 +108,12 @@ + + + Square44x44Logo.targetsize-44_altform-unplated.png + %(display-name)s + + diff --git a/installer/picard-setup.nsi.in b/installer/picard-setup.nsi.in index fc1fd5204e7..4b28b05473f 100644 --- a/installer/picard-setup.nsi.in +++ b/installer/picard-setup.nsi.in @@ -8,6 +8,7 @@ !define PRODUCT_DESCRIPTION "%(description)s" !define PRODUCT_URL "%(url)s" !define PRODUCT_HELP_URL "https://picard-docs.musicbrainz.org/" +!define PRODUCT_CUSTOM_PROTOCOL "%(protocol)s" !define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" !define PRODUCT_UNINST_ROOT_KEY "HKLM" @@ -142,6 +143,11 @@ Section "!$(SectionRequired)" required ; Write the installation path into the registry WriteRegStr HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" "InstallDir" "$INSTDIR" + ; Register a custom protocol handler + WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}" "URL Protocol" "" + WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}\DefaultIcon" "" "$INSTDIR\picard.exe,1" + WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}\shell\open\command" "" "$INSTDIR\picard.exe %%1" + ; Create uninstaller WriteUninstaller "$INSTDIR\uninst.exe" WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}" @@ -199,6 +205,7 @@ Section Uninstall DeleteRegKey ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" DeleteRegKey HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" + DeleteRegKey HKCR "${PRODUCT_CUSTOM_PROTOCOL}" !insertmacro INSTALLOPTIONS_READ $R0 "removeSettings.ini" "Field 1" "State" StrCmp $R0 "1" 0 +2 diff --git a/org.musicbrainz.Picard.desktop.in b/org.musicbrainz.Picard.desktop.in index a35b70faca5..e04f83b8e4b 100644 --- a/org.musicbrainz.Picard.desktop.in +++ b/org.musicbrainz.Picard.desktop.in @@ -1,16 +1,16 @@ [Desktop Entry] Name=MusicBrainz Picard Comment=Tag your music with the next generation MusicBrainz tagger -Exec=picard %F +Exec=picard %U Terminal=false Type=Application StartupNotify=true StartupWMClass=Picard Icon=org.musicbrainz.Picard Categories=AudioVideo;Audio;AudioVideoEditing; -MimeType=application/ogg;application/x-flac;audio/aac;audio/ac3;audio/aiff;audio/ape;audio/dsf;audio/flac;audio/midi;audio/mp4;audio/mpeg;audio/mpeg4;audio/mpg;audio/ogg;audio/vorbis;audio/x-aac;audio/x-aiff;audio/x-ape;audio/x-flac;audio/x-flac+ogg;audio/x-m4a;audio/x-midi;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-ms-wma;audio/x-ms-wmv;audio/x-musepack;audio/x-oggflac;audio/x-speex;audio/x-speex+ogg;audio/x-tak;audio/x-tta;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-wavpack;audio/x-wma;video/x-ms-asf;video/x-theora;video/x-wmv; +MimeType=x-scheme-handler/org.musicbrainz.picard;application/ogg;application/x-flac;audio/aac;audio/ac3;audio/aiff;audio/ape;audio/dsf;audio/flac;audio/midi;audio/mp4;audio/mpeg;audio/mpeg4;audio/mpg;audio/ogg;audio/vorbis;audio/x-aac;audio/x-aiff;audio/x-ape;audio/x-flac;audio/x-flac+ogg;audio/x-m4a;audio/x-midi;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-ms-wma;audio/x-ms-wmv;audio/x-musepack;audio/x-oggflac;audio/x-speex;audio/x-speex+ogg;audio/x-tak;audio/x-tta;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-wavpack;audio/x-wma;video/x-ms-asf;video/x-theora;video/x-wmv; Actions=new-window; [Desktop Action new-window] Name=New Window -Exec=picard --stand-alone-instance %F +Exec=picard --stand-alone-instance %U diff --git a/picard.spec b/picard.spec index 33530bb8848..218107befd5 100644 --- a/picard.spec +++ b/picard.spec @@ -21,6 +21,7 @@ from picard import ( PICARD_APP_NAME, PICARD_DISPLAY_NAME, PICARD_ORG_NAME, + PICARD_PROTOCOL_SCHEME, PICARD_VERSION, __version__, ) @@ -230,6 +231,13 @@ else: 'CFBundleTypeRole': 'Editor', } ], + 'CFBundleURLTypes': [ + { + 'CFBundleURLName': PICARD_APP_ID, + 'CFBundleURLSchemes': [PICARD_PROTOCOL_SCHEME], + 'CFBundleURLIconFile': 'picard', + } + ], } # Add additional supported file types by extension diff --git a/picard/__init__.py b/picard/__init__.py index 7fe6ef48671..42de88e6a1f 100644 --- a/picard/__init__.py +++ b/picard/__init__.py @@ -50,7 +50,6 @@ PICARD_VERSION = Version(3, 0, 0, 'beta', 9) COPYRIGHT_YEARS = "2004-2026" - PICARD_VERSION_STR = str(PICARD_VERSION) __version__ = PICARD_VERSION.short_str() PICARD_FANCY_VERSION_STR = __version__ @@ -63,6 +62,9 @@ __version__ = f"{__version__}+{PICARD_BUILD_VERSION_STR}" PICARD_FANCY_VERSION_STR = f"{PICARD_FANCY_VERSION_STR} ({PICARD_BUILD_VERSION_STR})" +# Custom protocol for browser integration +PICARD_PROTOCOL_SCHEME = 'org.musicbrainz.picard' + # Keep those ordered api_versions = ("3.0",) diff --git a/picard/browser/server.py b/picard/browser/server.py index 9f58f1bf36b..b2150cf25af 100644 --- a/picard/browser/server.py +++ b/picard/browser/server.py @@ -36,10 +36,12 @@ ) from PyQt6 import QtCore +from PyQt6.QtGui import QDesktopServices from picard import ( PICARD_APP_NAME, PICARD_ORG_NAME, + PICARD_PROTOCOL_SCHEME, PICARD_VERSION_STR, log, tagger_instance, @@ -80,6 +82,8 @@ class BrowserIntegration(QtCore.QObject): def __init__(self, parent=None): super().__init__(parent) self.server = None + self._action_handler = RequestActionHandler() + QDesktopServices.setUrlHandler(PICARD_PROTOCOL_SCHEME, self.url_handler) @property def host_address(self): @@ -99,7 +103,7 @@ def is_running(self): def start(self): if self.server: - self.stop() + self.stop(stop_url_handler=False) config = get_config() @@ -134,7 +138,7 @@ def start(self): except Exception: log.error("%s: Failed to start listening on %s", LOG_PREFIX, host_address, exc_info=True) - def stop(self): + def stop(self, stop_url_handler=True): if self.server: try: log.info("%s: Stopping", LOG_PREFIX) @@ -147,6 +151,19 @@ def stop(self): else: log.debug("%s: inactive, no need to stop", LOG_PREFIX) + if stop_url_handler: + QDesktopServices.unsetUrlHandler(PICARD_PROTOCOL_SCHEME) + + def url_handler(self, url: QtCore.QUrl): + """URL handler used for custom protocol handling.""" + log.debug("Received custom URL: %s", url.toString()) + + if url.scheme() != PICARD_PROTOCOL_SCHEME: + log.error("Invalid URL scheme: %s", url.scheme()) + return + + self._action_handler.handle_get(url.toString()) + # From https://github.com/python/cpython/blob/f474264b1e3cd225b45cf2c0a91226d2a9d3ee9b/Lib/http/server.py#L570C1-L573C43 # https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes @@ -159,6 +176,10 @@ def safe_message(message): class RequestHandler(BaseHTTPRequestHandler): + def __init__(self, *args, **kwargs): + self._action_handler = RequestActionHandler(self._response) + super().__init__(*args, **kwargs) + def do_OPTIONS(self): origin = self.headers['origin'] if _is_valid_origin(origin): @@ -184,7 +205,7 @@ def do_GET(self): return try: - self._handle_get() + self._action_handler.handle_get(self.path) except Exception: log.error('%s: failed handling request', LOG_PREFIX, exc_info=True) self._response(500, 'Unexpected request error') @@ -203,10 +224,33 @@ def log_error(self, format, *args): def log_message(self, format, *args): self._log(log.info, format, args) - def _handle_get(self): - parsed = urlparse(self.path) + def _response(self, code, content='', content_type='text/plain'): + self.server_version = SERVER_VERSION + self.send_response(code) + self.send_header('Content-Type', content_type) + self.send_header('Cache-Control', 'max-age=0') + origin = self.headers['origin'] + if _is_valid_origin(origin): + self.send_header('Access-Control-Allow-Origin', clean_header(origin)) + self.send_header('Vary', 'Origin') + self.end_headers() + self.wfile.write(content.encode()) + + +class RequestActionHandler: + """ + Handles URL requests by executing the appropriate action based on URL path. + """ + + def __init__(self, response_handler=None): + self._response_handler = response_handler + + def handle_get(self, path): + parsed = urlparse(path) args = parse_qs(parsed.query) action = parsed.path + if not action.startswith('/'): + action = '/' + action if action == '/': self._response(200, SERVER_VERSION) @@ -219,6 +263,7 @@ def _handle_get(self): elif action == '/auth': self._auth(args) else: + log.error('Unknown browser action: %s', action) self._response(404, 'Unknown action.') def _load_mbid(self, type, args): @@ -266,16 +311,11 @@ def _auth(self, args): self._response(400, 'Missing parameter "code".') def _response(self, code, content='', content_type='text/plain'): - self.server_version = SERVER_VERSION - self.send_response(code) - self.send_header('Content-Type', content_type) - self.send_header('Cache-Control', 'max-age=0') - origin = self.headers['origin'] - if _is_valid_origin(origin): - self.send_header('Access-Control-Allow-Origin', clean_header(origin)) - self.send_header('Vary', 'Origin') - self.end_headers() - self.wfile.write(content.encode()) + if not self._response_handler: + log.debug(f'Finished custom request with code {code}') + return + + self._response_handler(code, content, content_type) def clean_header(header): diff --git a/picard/remotecommands/handlers.py b/picard/remotecommands/handlers.py index d04a817cb4c..cdd6ab37877 100644 --- a/picard/remotecommands/handlers.py +++ b/picard/remotecommands/handlers.py @@ -48,6 +48,7 @@ from urllib.parse import urlparse from picard import ( + PICARD_PROTOCOL_SCHEME, log, tagger_instance, ) @@ -71,6 +72,7 @@ def __init__(self, items): self.files = set() self.mbids = set() self.urls = set() + self.custom_urls = set() for item in items: parsed = urlparse(item) @@ -85,6 +87,8 @@ def __init__(self, items): elif parsed.scheme in {'http', 'https'}: # .path returns / before actual link self.urls.add(parsed.path[1:]) + elif parsed.scheme == PICARD_PROTOCOL_SCHEME: + self.custom_urls.add(item) elif IS_WIN and self.WINDOWS_DRIVE_TEST.match(item): # Treat all single-character schemes as part of the file spec to allow # specifying a drive identifier on Windows systems. diff --git a/picard/tagger.py b/picard/tagger.py index 216d5a67788..4940e380ff2 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -77,6 +77,7 @@ PICARD_APP_NAME, PICARD_FANCY_VERSION_STR, PICARD_ORG_NAME, + PICARD_PROTOCOL_SCHEME, acoustid, config as _cfg, log, @@ -847,7 +848,7 @@ def update_browser_integration(self): if config.setting['browser_integration']: self.browser_integration.start() else: - self.browser_integration.stop() + self.browser_integration.stop(stop_url_handler=False) _BATCH_TIME_BUDGET = 0.050 # 50ms per batch (~20 yields/sec) _callback_queue: ClassVar[list] = [] @@ -887,14 +888,19 @@ def event(self, event): self._callback_timer_running = True QtCore.QTimer.singleShot(0, self._process_callback_batch) elif event.type() == QtCore.QEvent.Type.FileOpen: - file = event.file() - self.add_paths([file]) - if IS_HAIKU: - self.bring_tagger_front() - # We should just return True here, except that seems to - # cause the event's sender to get a -9874 error, so - # apparently there's some magic inside QFileOpenEvent... - return 1 + url = event.url() + log.debug('Received file open event: %r', url) + if url.isLocalFile(): + self.add_paths([url.toLocalFile()]) + if IS_HAIKU: + self.bring_tagger_front() + # We should just return True here, except that seems to + # cause the event's sender to get a -9874 error, so + # apparently there's some magic inside QFileOpenEvent... + return 1 + elif url.scheme() == PICARD_PROTOCOL_SCHEME: + self.browser_integration.url_handler(url) + return 1 return super().event(event) def _process_callback_batch(self) -> None: @@ -1720,7 +1726,7 @@ def process_cmdline_args(): args.processable = [] for path in args.FILE_OR_URL: - if not urlparse(path).netloc: + if not urlparse(path).scheme: try: path = os.path.abspath(path) except FileNotFoundError: diff --git a/setup.py b/setup.py index 608271d8282..da33da28fb9 100755 --- a/setup.py +++ b/setup.py @@ -70,6 +70,7 @@ PICARD_APP_NAME, PICARD_DESKTOP_NAME, PICARD_DISPLAY_NAME, + PICARD_PROTOCOL_SCHEME, PICARD_VERSION, ) @@ -164,6 +165,7 @@ def run(self): installer_args = { 'display-name': PICARD_DISPLAY_NAME, 'file-version': file_version_str, + 'protocol': PICARD_PROTOCOL_SCHEME, } if os.path.isfile('installer/picard-setup.nsi.in'): generate_file( @@ -419,6 +421,7 @@ def run(self): 'short-name': PICARD_APP_NAME, 'publisher': os.environ.get('PICARD_APPX_PUBLISHER', default_publisher), 'version': '.'.join(str(v) for v in store_version), + 'protocol': PICARD_PROTOCOL_SCHEME, }, )