Skip to content

feat(macos): add macOS protocol handler support - #135

Open
xlilu wants to merge 7 commits into
akiirui:mainfrom
xlilu:macos-support
Open

xlilu wants to merge 7 commits into
akiirui:mainfrom
xlilu:macos-support

Conversation

@xlilu

@xlilu xlilu commented Nov 9, 2026

Copy link
Copy Markdown

Why

mpv-handler cannot be used on macOS at all today, for four reasons I verified empirically on macOS 15 (arm64):

  1. macOS has no equivalent of a .desktop file or a registry entry: a URL scheme handler has to be an application bundle, so the bare binary cannot be registered.
  2. LaunchServices does not pass the URL in argv; it delivers it with the GURL Apple Event (argc == 1 when launched for a URL).
  3. A process started by LaunchServices only inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin, so a Homebrew/MacPorts mpv or yt-dlp is invisible and realpath("mpv") fails.
  4. Such a process has no console, so errors are invisible to the user.

What

  • src/macos.rs (new): a minimal AppKit event loop built on objc2 (define_class! + NSAppleEventManager) that receives GURL, dispatches it through the existing plugin path and exits. terminate: is called after dispatching, so every URL is handled by a fresh process and a long playback never blocks the next one. A 30 s watchdog exits the bundle when LaunchServices starts it without a URL (e.g. a double click in the Finder).
  • src/config.rs: on macOS, mpv/yt-dlp are additionally looked up in /opt/homebrew/bin, /usr/local/bin, /opt/local/bin, $HOME/.local/bin, $HOME/bin and inside mpv.app bundles; the config directory becomes $HOME/.config/mpv-handler (with $XDG_CONFIG_HOME taking precedence, and ~/Library/Application Support kept as a fallback when it already exists) so the layout matches Linux.
  • src/plugins/play.rs: build_command is split out of exec, and exec_detached starts mpv without waiting, which is what the URL service needs. DYLD_* variables are removed for the same reason LD_* already were.
  • src/main.rs: with no arguments the binary enters the URL service only when it was started by LaunchServices (getppid() == 1); from a terminal it still prints the usage, so the CLI keeps working.
  • Debug scheme / errors: mpv-handler-debug:// opens Terminal and re-runs the CLI there (mirroring the Windows console window); errors of the normal scheme are shown with display alert, since there is no console.
  • share/macos/: Info.plist (one bundle claims both schemes, LSUIElement), install.sh (assembles the bundle, clears com.apple.quarantine from the copy before anything executes it, ad-hoc signs it, registers it with LaunchServices, seeds the config dir, sets the default handler when duti is present) and uninstall.sh.
  • CI: a build-macos job builds both targets and merges them with lipo; the release is packaged as mpv-handler-macos-universal.zip.
  • Docs: a macOS section in all three READMEs; the previously unused [download-macos] link is now used and points at the universal archive.

Linux and Windows behaviour is unchanged; all platform-specific code is cfg-gated.

Testing (macOS 15.8.1, Apple Silicon)

  • open "mpv-handler://play/<base64url>/?…" plays through Homebrew mpv with the expected --profile / format-sort / --title / --start / cookies arguments.
  • Two URLs opened back to back produce two concurrent mpv instances.
  • mpv-handler-debug:// runs the CLI inside Terminal.
  • A dangerous protocol (file://) shows the alert instead of failing silently.
  • Launching the bundle without a URL exits after the watchdog timeout.
  • install.sh / uninstall.sh round-trip cleanly, including an archive carrying com.apple.quarantine (no Gatekeeper "damaged" dialog).
  • cargo test: 13 passed. cargo fmt --check: clean. cargo clippy --all-targets: the same 12 pre-existing warnings as main, i.e. no new lint.

Notes for review

  • The Apple Event path uses objc2 with default-features = false, which adds only seven crates to Cargo.lock.
  • getppid() == 1 is what distinguishes a LaunchServices launch from a terminal one; __CFBundleIdentifier was rejected because child processes inherit it.
  • Happy to split the CI/docs changes into separate commits if you prefer a smaller first patch.

@xlilu xlilu mentioned this pull request Nov 9, 2026
@xlilu

xlilu commented Nov 10, 2026

Copy link
Copy Markdown
Author

Follow-up fix pushed (5ee5871), found while dogfooding the macOS build with the play-with-mpv userscript:

mpv's ytdl_hook resolves yt-dlp through its own $PATH unless --script-opts=ytdl_hook-ytdl_path is given, and that option was only passed when ytdl was set in config.toml. A bundle started by LaunchServices inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin, so with the default config extraction failed with [ytdl_hook] youtube-dl failed: not found or not enough permissions and mpv exited before a window could appear — while the very same URL played fine from a terminal, where $PATH contains the package manager directories.

The fix resolves yt-dlp with the same lookup already used for mpv and always passes the resulting path to the player. On macOS it also prepends the extra binary directories to the child's PATH, because yt-dlp shells out to ffmpeg and ffprobe by name whenever it has to merge separate video and audio streams.

Verified from a real browser click with the userscript's console=no scheme: bilibili and YouTube both open a window and play, where before they exited silently.

@akiirui

akiirui commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Thanks, Let me test the modified code in a couple of days, when I have some free time, to see if it affects other platforms.

@akiirui akiirui self-assigned this Nov 10, 2026
@akiirui akiirui added the enhancement New feature or request label Nov 10, 2026
macOS cannot register a bare executable as a URL scheme handler, and
LaunchServices hands the URL over with the GURL Apple Event instead of
the command line, so the binary could not be used there at all. A
process started that way also only inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin,
which hides Homebrew and MacPorts installations, and it has no console
to report errors to.

- Run a minimal AppKit event loop (objc2) that receives the GURL event,
  dispatches it and exits, so every URL is handled by a fresh process
  and a long playback never blocks the next one
- Look up mpv and yt-dlp in /opt/homebrew/bin, /usr/local/bin,
  /opt/local/bin, $HOME/.local/bin, $HOME/bin and inside mpv.app when
  they are not on $PATH
- Use $HOME/.config/mpv-handler as the config directory so the layout
  matches Linux, with $XDG_CONFIG_HOME taking precedence
- Open Terminal for mpv-handler-debug:// and show an alert on errors,
  because a bundle started by LaunchServices has no console
- Add share/macos install/uninstall scripts that assemble, ad-hoc sign
  and register mpv-handler.app with LaunchServices, clearing
  com.apple.quarantine from the copy before anything executes it
- Build a universal binary in CI and document macOS in all three READMEs
actions/upload-artifact does not preserve file modes, so the Linux and
macOS binaries were packaged into the release archives as 644. share/macos/install.sh
compensates by chmod-ing the bundle copy, but the archives themselves
should carry the executable bit.
mpv's ytdl_hook looks yt-dlp up in its own $PATH unless
--script-opts=ytdl_hook-ytdl_path is given. That option was only passed
when `ytdl` was set in config.toml, so with the default config a bundle
started by LaunchServices (PATH=/usr/bin:/bin:/usr/sbin:/sbin) failed
extraction with "youtube-dl failed: not found or not enough permissions"
and exited before a window could appear, while the very same URL played
fine from a terminal, where $PATH contains the package manager dirs.

Resolve yt-dlp with the same lookup used for mpv and always pass the
resulting path to the player.

Also prepend the extra binary directories to the child's PATH on macOS,
because yt-dlp shells out to ffmpeg and ffprobe by name whenever it has
to merge separate video and audio streams.

Verified on macOS 15 (Apple Silicon) by triggering the handler from a
browser with the userscript's console=no scheme: bilibili and YouTube
both open a window and play, where before they exited silently.
share/macos/remote-install.sh downloads the latest release archive,
verifies it against the sha512sum asset shipped in the same release over
the same TLS connection, and then runs the install.sh from inside the
archive, so a whole install is one paste into a terminal:

    curl -fsSL https://raw.githubusercontent.com/akiirui/mpv-handler/main/share/macos/remote-install.sh | sh

MPV_HANDLER_REPO and MPV_HANDLER_TAG select a different repository or pin
a release; any extra arguments are forwarded to install.sh, e.g.
`| sh -s -- -d /Applications`.
Drop the shell prompt from the curl | sh example so it can be pasted
verbatim, and rename the section to spell out that it is a command.
A variable prefix before curl only reaches curl, not the sh reading the
pipe, so document the working form: set it on the sh after the pipe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants