1.The Custom Scheme
The Custom Scheme
Custom URL scheme: tauri-shellscript-manager://
Example: tauri-shellscript-manager://open?scriptId=87
2.How It Works (End-to-End)
How It Works (End-to-End)
3.Setup Checklist
Setup Checklist
3.1.tauri.conf.json
tauri.conf.json
Permission must be present in the main-window capability:
deep-link:default is a Tauri ACL permission bundle defined by the plugin
itself. Tauri 2.x denies every plugin IPC command to the WebView by default;
plugins ship named permission sets that grant groups of commands per window.
deep-link:default specifically allows the WebView to call:
| Command | JS API | Purpose |
|---|---|---|
get_current_url | getCurrent() | Read the URL that launched the app (cold-start) |
is_registered | isRegistered() | Check whether a scheme is registered |
onOpenUrl does not need this permission — it subscribes to the
deep-link://new-url event that Rust emits, and events bypass the ACL.
But getCurrent() is a tauri::command invoke, so without deep-link:default
in the capability list it throws "Command get_current_url not found".
3.2.getCurrent() vs onOpenUrl — cold-start vs already-running
getCurrent() vs onOpenUrl — cold-start vs already-running
There are two scenarios when a deep link fires:
- App already running —
onOpenUrlcatches it. The listener is registered and the event arrives normally. - App not yet running (cold-start) — macOS launches the app because of
the deep link. By the time React mounts and
onOpenUrlregisters its listener, the initial URL event has already been delivered at the Rust/tao level and is gone. The callback never fires because the listener didn't exist yet.
getCurrent() solves the cold-start case. Call it once on startup: if it
returns a URL, the app was opened by a deep link; if it returns null, the
app was launched normally.
Together they cover both cases:
3.4.lib.rs — Plugin must be registered
lib.rs — Plugin must be registered
3.5.lib.rs — Custom delegate MUST implement application:openURLs:
lib.rs — Custom delegate MUST implement application:openURLs:
The lib.rs Of Concern:
This project uses a custom TauriAppDelegate (to intercept Cmd+Q via
applicationShouldTerminate:).
Problem. Replacing the default tao delegate also
removes application:openURLs:, which silently drops all deep link events.
The fix is to add application:openURLs: directly to TauriAppDelegate:
3.6.App.tsx — JS listener
App.tsx — JS listener
onOpenUrl is imported from @tauri-apps/plugin-deep-link. It subscribes to
the deep-link://new-url IPC event that the Rust side emits (either through
tao's default delegate, or — in our case — directly via APP_HANDLE.emit).
Under the hood onOpenUrl:
- Registers an IPC listener in the WebView for the
deep-link://new-urlchannel when the component first mounts. - Receives the URL array that Rust serialises and passes over Tauri's
bridge. A single
opencommand can carry more than one URL, hence the array. - Returns an unlisten function (similar to
addEventListenerreturning a function you call to remove the listener). We should call it on unmount to avoid stale listeners accumulating across hot-reloads.
Why the scheme-swap trick? new URL("tauri-shellscript-manager://open?scriptId=87") throws
in most browsers because the scheme is unknown. Replacing the scheme with
http://placeholder/ gives the parser a valid base URL and preserves pathname
(/open) and query string (?scriptId=87) exactly.
4.Problems Encountered and Fixes
Problems Encountered and Fixes
4.1.Problem 1 — LaunchServices database pollution
Problem 1 — LaunchServices database pollution
Symptom: open "tauri-shellscript-manager://..." does nothing. App doesn't receive the URL.
Cause: Every time a .dmg is opened (without ejecting), macOS registers
/Volumes/dmg.xxx/shell-script-manager.app as an additional URL handler. After
many test builds, 50+ stale entries accumulated. macOS dispatched Apple Events
to those stale, non-existent paths instead of /Applications/shell-script-manager.app.
Fix:
Prevention: Always eject the DMG after dragging to Applications. Or install by copying directly from the build output:
4.2.Problem 2 — Custom NSApplicationDelegate swallows URL events (ROOT CAUSE)
Problem 2 — Custom NSApplicationDelegate swallows URL events (ROOT CAUSE)
Symptom: LS database is clean, app is running, onOpenUrl listener is
registered — but callback never fires.
Cause: setup_app_delegate() in lib.rs calls [NSApp setDelegate: TauriAppDelegate].
This completely replaces tao's default delegate. tao's delegate is the one
that implements application:openURLs: to forward deep links into Tauri's
RunEvent::Opened. With only applicationShouldTerminate: on the custom
delegate, every deep link Apple Event was silently dropped.
Fix: Add application:openURLs: to TauriAppDelegate (see code above).
This emits deep-link://new-url directly via APP_HANDLE, which is the same
event the plugin would have emitted through tao.
Rule of thumb: Any time we replace the
NSApplicationDelegatein a Tauri app, we must re-implement every method tao relied on, or delegate to tao's original delegate via[super ...].
4.3.Problem 3 — ACL permission missing
Problem 3 — ACL permission missing
Symptom: getCurrent() / onOpenUrl throws an error like
"Command get_current_url not found".
Fix: Add "deep-link:default" to the window's permissions in
tauri.conf.json under app.security.capabilities.
5.Testing
Testing
When clicking a deep link in a browser (Safari/Chrome), the browser shows a
confirmation dialog — click Allow/Open. The terminal open command skips
this dialog, making it better for isolated testing.








