Skip to content

Commit b1dcda6

Browse files
committed
Register a custom protocol handler for browser integration
1 parent 4309634 commit b1dcda6

File tree

8 files changed

+87
-20
lines changed

8 files changed

+87
-20
lines changed

appxmanifest.xml.in

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@
9494
</uap:SupportedFileTypes>
9595
</uap:FileTypeAssociation>
9696
</uap:Extension>
97+
<uap:Extension Category="windows.protocol">
98+
<uap:Protocol Name="%(protocol)s">
99+
<uap:Logo>Square44x44Logo.targetsize-44_altform-unplated.png</uap:Logo>
100+
<uap:DisplayName>%(display-name)s</uap:DisplayName>
101+
</uap:Protocol>
102+
</uap:Extension>
97103
</Extensions>
98104
</Application>
99105
</Applications>

installer/picard-setup.nsi.in

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
!define PRODUCT_DESCRIPTION "%(description)s"
99
!define PRODUCT_URL "%(url)s"
1010
!define PRODUCT_HELP_URL "https://picard-docs.musicbrainz.org/"
11+
!define PRODUCT_CUSTOM_PROTOCOL "%(protocol)s"
1112
!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
1213
!define PRODUCT_UNINST_ROOT_KEY "HKLM"
1314

@@ -142,6 +143,11 @@ Section "!$(SectionRequired)" required
142143
; Write the installation path into the registry
143144
WriteRegStr HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" "InstallDir" "$INSTDIR"
144145

146+
; Register a custom protocol handler
147+
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}" "URL Protocol" ""
148+
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}" "DefaultIcon" "$INSTDIR\picard.exe,1"
149+
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}\shell\open" "command" "$INSTDIR\picard.exe %%1"
150+
145151
; Create uninstaller
146152
WriteUninstaller "$INSTDIR\uninst.exe"
147153
WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}"
@@ -199,6 +205,7 @@ Section Uninstall
199205

200206
DeleteRegKey ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}"
201207
DeleteRegKey HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}"
208+
DeleteRegKey HKCR "${PRODUCT_CUSTOM_PROTOCOL}"
202209

203210
!insertmacro INSTALLOPTIONS_READ $R0 "removeSettings.ini" "Field 1" "State"
204211
StrCmp $R0 "1" 0 +2

org.musicbrainz.Picard.desktop.in

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
[Desktop Entry]
22
Name=MusicBrainz Picard
33
Comment=Tag your music with the next generation MusicBrainz tagger
4-
Exec=picard %F
4+
Exec=picard %U
55
Terminal=false
66
Type=Application
77
StartupNotify=true
88
StartupWMClass=Picard
99
Icon=org.musicbrainz.Picard
1010
Categories=AudioVideo;Audio;AudioVideoEditing;
11-
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;
11+
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;
1212
Actions=new-window;
1313

1414
[Desktop Action new-window]
1515
Name=New Window
16-
Exec=picard --stand-alone-instance %F
16+
Exec=picard --stand-alone-instance %U

picard.spec

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ sys.path.insert(0, '.')
1010
from picard import (
1111
PICARD_APP_ID,
1212
PICARD_APP_NAME,
13+
PICARD_CUSTOM_PROTOCOL,
1314
PICARD_DISPLAY_NAME,
1415
PICARD_ORG_NAME,
1516
PICARD_VERSION,
@@ -163,6 +164,9 @@ else:
163164
],
164165
'CFBundleTypeRole': 'Editor',
165166
}],
167+
'CFBundleURLTypes': [{
168+
'CFBundleURLSchemes': [PICARD_CUSTOM_PROTOCOL],
169+
}],
166170
}
167171

168172
# Add additional supported file types by extension

picard/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
PICARD_DESKTOP_NAME = PICARD_APP_ID + ".desktop"
4545
PICARD_VERSION = Version(3, 0, 0, 'dev', 5)
4646

47+
# Custom protocol for browser integration
48+
PICARD_CUSTOM_PROTOCOL = 'org.musicbrainz.picard'
4749

4850
# optional build version
4951
# it should be in the form '<platform>_<YYMMDDHHMMSS>'

picard/browser/browser.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# Copyright (C) 2006-2007, 2011 Lukáš Lalinský
66
# Copyright (C) 2011-2013 Michael Wiencek
77
# Copyright (C) 2012 Chad Wilson
8-
# Copyright (C) 2012-2013, 2018, 2021-2022, 2024 Philipp Wolfer
8+
# Copyright (C) 2012-2013, 2018, 2021-2022, 2024-2025 Philipp Wolfer
99
# Copyright (C) 2013, 2018, 2020-2021, 2024 Laurent Monin
1010
# Copyright (C) 2016 Suhas
1111
# Copyright (C) 2016-2017 Sambhav Kothari
@@ -38,9 +38,11 @@
3838
)
3939

4040
from PyQt6 import QtCore
41+
from PyQt6.QtGui import QDesktopServices
4142

4243
from picard import (
4344
PICARD_APP_NAME,
45+
PICARD_CUSTOM_PROTOCOL,
4446
PICARD_ORG_NAME,
4547
PICARD_VERSION_STR,
4648
log,
@@ -86,6 +88,8 @@ class BrowserIntegration(QtCore.QObject):
8688
def __init__(self, parent=None):
8789
super().__init__(parent)
8890
self.server = None
91+
self._action_handler = RequestActionHandler()
92+
QDesktopServices.setUrlHandler(PICARD_CUSTOM_PROTOCOL, self.url_handler)
8993

9094
@property
9195
def host_address(self):
@@ -129,7 +133,7 @@ def start(self):
129133
except Exception:
130134
log.error("Failed starting the browser integration on %s", host_address, exc_info=True)
131135

132-
def stop(self):
136+
def stop(self, stop_url_handler=False):
133137
if self.server:
134138
try:
135139
log.info("Stopping the browser integration")
@@ -142,9 +146,24 @@ def stop(self):
142146
else:
143147
log.debug("Browser integration inactive, no need to stop")
144148

149+
if stop_url_handler:
150+
QDesktopServices.unsetUrlHandler(PICARD_CUSTOM_PROTOCOL)
151+
152+
def url_handler(self, url: QtCore.QUrl):
153+
"""URL handler used for custom protocol handling."""
154+
if url.scheme() != PICARD_CUSTOM_PROTOCOL:
155+
log.error("Invalid URL scheme: %s", url.scheme())
156+
return
157+
158+
self._action_handler.handle_get(url.toString())
159+
145160

146161
class RequestHandler(BaseHTTPRequestHandler):
147162

163+
def __init__(self, *args, **kwargs):
164+
self._action_handler = RequestActionHandler(self._response)
165+
super().__init__(*args, **kwargs)
166+
148167
def do_OPTIONS(self):
149168
origin = self.headers['origin']
150169
if _is_valid_origin(origin):
@@ -161,7 +180,7 @@ def do_OPTIONS(self):
161180

162181
def do_GET(self):
163182
try:
164-
self._handle_get()
183+
self._action_handler.handle_get(self.path)
165184
except Exception:
166185
log.error('Browser integration failed handling request', exc_info=True)
167186
self._response(500, 'Unexpected request error')
@@ -172,10 +191,32 @@ def log_error(self, format, *args):
172191
def log_message(self, format, *args):
173192
log.info(format, *args)
174193

175-
def _handle_get(self):
176-
parsed = urlparse(self.path)
194+
def _response(self, code, content='', content_type='text/plain'):
195+
self.server_version = SERVER_VERSION
196+
self.send_response(code)
197+
self.send_header('Content-Type', content_type)
198+
self.send_header('Cache-Control', 'max-age=0')
199+
origin = self.headers['origin']
200+
if _is_valid_origin(origin):
201+
self.send_header('Access-Control-Allow-Origin', origin)
202+
self.send_header('Vary', 'Origin')
203+
self.end_headers()
204+
self.wfile.write(content.encode())
205+
206+
207+
class RequestActionHandler:
208+
"""
209+
Handles URL requests by executing the appropriate action based on URL path.
210+
"""
211+
def __init__(self, response_handler=None):
212+
self._response_handler = response_handler
213+
214+
def handle_get(self, path):
215+
parsed = urlparse(path)
177216
args = parse_qs(parsed.query)
178217
action = parsed.path
218+
if not action.startswith('/'):
219+
action = '/' + action
179220

180221
if action == '/':
181222
self._response(200, SERVER_VERSION)
@@ -188,6 +229,7 @@ def _handle_get(self):
188229
elif action == '/auth':
189230
self._auth(args)
190231
else:
232+
log.error('Unknown browser action: %s', action)
191233
self._response(404, 'Unknown action.')
192234

193235
def _load_mbid(self, type, args):
@@ -235,13 +277,8 @@ def _auth(self, args):
235277
self._response(400, 'Missing parameter "code".')
236278

237279
def _response(self, code, content='', content_type='text/plain'):
238-
self.server_version = SERVER_VERSION
239-
self.send_response(code)
240-
self.send_header('Content-Type', content_type)
241-
self.send_header('Cache-Control', 'max-age=0')
242-
origin = self.headers['origin']
243-
if _is_valid_origin(origin):
244-
self.send_header('Access-Control-Allow-Origin', origin)
245-
self.send_header('Vary', 'Origin')
246-
self.end_headers()
247-
self.wfile.write(content.encode())
280+
if not self._response_handler:
281+
log.debug(f'Finished custom request with code {code}')
282+
return
283+
284+
self._response_handler(code, content, content_type)

picard/tagger.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
from picard import (
7070
PICARD_APP_ID,
7171
PICARD_APP_NAME,
72+
PICARD_CUSTOM_PROTOCOL,
7273
PICARD_FANCY_VERSION_STR,
7374
PICARD_ORG_NAME,
7475
acoustid,
@@ -191,6 +192,7 @@ def __init__(self, items):
191192
self.files = set()
192193
self.mbids = set()
193194
self.urls = set()
195+
self.custom_urls = set()
194196

195197
for item in items:
196198
parsed = urlparse(item)
@@ -205,6 +207,8 @@ def __init__(self, items):
205207
elif parsed.scheme in {'http', 'https'}:
206208
# .path returns / before actual link
207209
self.urls.add(parsed.path[1:])
210+
elif parsed.scheme == PICARD_CUSTOM_PROTOCOL:
211+
self.custom_urls.add(item)
208212
elif IS_WIN and self.WINDOWS_DRIVE_TEST.match(item):
209213
# Treat all single-character schemes as part of the file spec to allow
210214
# specifying a drive identifier on Windows systems.
@@ -519,6 +523,10 @@ def handle_command_load(self, argstring):
519523
for item in parsed_items.mbids | parsed_items.urls:
520524
file_lookup.mbid_lookup(item)
521525

526+
if parsed_items.custom_urls:
527+
for item in parsed_items.custom_urls:
528+
self.browser_integration.url_handler(QtCore.QUrl(item))
529+
522530
def handle_command_lookup(self, argstring):
523531
if argstring:
524532
argstring = argstring.upper()
@@ -765,7 +773,7 @@ def exit(self):
765773
if self.priority_thread_pool:
766774
self.priority_thread_pool.waitForDone()
767775
if self.browser_integration:
768-
self.browser_integration.stop()
776+
self.browser_integration.stop(stop_url_handler=True)
769777
self.run_cleanup()
770778
QtCore.QCoreApplication.processEvents()
771779

@@ -1517,7 +1525,7 @@ def process_picard_args():
15171525

15181526
args.processable = []
15191527
for path in args.FILE_OR_URL:
1520-
if not urlparse(path).netloc:
1528+
if not urlparse(path).scheme:
15211529
try:
15221530
path = os.path.abspath(path)
15231531
except FileNotFoundError:

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from picard import ( # noqa: E402
6464
PICARD_APP_ID,
6565
PICARD_APP_NAME,
66+
PICARD_CUSTOM_PROTOCOL,
6667
PICARD_DESKTOP_NAME,
6768
PICARD_DISPLAY_NAME,
6869
PICARD_VERSION,
@@ -167,6 +168,7 @@ def run(self):
167168
installer_args = {
168169
'display-name': PICARD_DISPLAY_NAME,
169170
'file-version': file_version_str,
171+
'protocol': PICARD_CUSTOM_PROTOCOL,
170172
}
171173
if os.path.isfile('installer/picard-setup.nsi.in'):
172174
generate_file('installer/picard-setup.nsi.in', 'installer/picard-setup.nsi', {**common_args, **installer_args})
@@ -190,6 +192,7 @@ def run(self):
190192
'short-name': PICARD_APP_NAME,
191193
'publisher': os.environ.get('PICARD_APPX_PUBLISHER', default_publisher),
192194
'version': '.'.join(str(v) for v in store_version),
195+
'protocol': PICARD_CUSTOM_PROTOCOL,
193196
})
194197
elif sys.platform not in {'darwin', 'haiku1', 'win32'}:
195198
self.run_command('build_appdata')

0 commit comments

Comments
 (0)