fix(v3): macOS SingleInstance + URL scheme — relay URL to first instance (#5089)#5170
fix(v3): macOS SingleInstance + URL scheme — relay URL to first instance (#5089)#5170leaanthony wants to merge 5 commits intomasterfrom
Conversation
…#5089) Adds v3/examples/single-instance-url-scheme/ — a minimal reproduction for wails#5089. The example enables SingleInstance and registers a custom URL scheme (wails-single-url://) via CFBundleURLTypes. Running the app and then triggering the scheme with a second process (e.g. `open`) exercises the bug: on macOS the URL is delivered by LaunchServices via an Apple Event that the exiting second process never installs a handler for, so neither OnSecondInstanceLaunch nor ApplicationLaunchedWithUrl sees the URL in the first instance. This is a failing test case only — no fix is included.
…dd trigger:force task On macOS 26.0 (tested: 25A354, Apple Silicon), LaunchServices routes URL-scheme Apple Events directly to the running instance, so plain `open URL` does NOT spawn a second process and the bug is not visible via that path. Add trigger:force task (open -n) to force a new process, which reproduces the bug on macOS 26+: second instance detects flock, relays os.Args (no URL), exits before Apple Event handler registers — URL dropped, OnSecondInstanceLaunch fires with url-in-args?=false. Update README with macOS version behaviour matrix and trigger:force instructions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…heme (#5089) On macOS, URL-scheme launches deliver the URL via NSAppleEventManager (kAEGetURL Apple Event) rather than os.Args. When SingleInstance is active, the second process was calling os.Exit() before NSApplication.run wired up the Apple Event handler, so the URL was silently dropped. Fix: add single_instance_darwin_url.go which implements captureLaunchURL(). It briefly runs a minimal NSApplication event loop (NSApplicationActivationPolicyProhibited, no dock icon) so LaunchServices can deliver the kAEGetURL event. The run loop exits immediately on URL capture or after a 300 ms safety-net timeout. The captured URL is appended to SecondInstanceData.Args in notifyFirstInstance(), matching the behaviour on Windows and Linux where the URL is already in os.Args. ApplicationLaunchedWithUrl is not fired on the second-instance relay path, consistent with Windows/Linux. Tested on macOS 26 (Apple Silicon): - open -n URL: url-in-args? = true, ~120-160 ms second-instance exit time - open -n app (no URL): url-in-args? = false, ~420 ms (300 ms timeout) - Fresh first-instance launch with URL: ApplicationLaunchedWithUrl fires Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughAdds a macOS-specific capture for URL-scheme launches in the SingleInstance flow: a short-lived NSApp run loop registers kAEGetURL, captures any launch URL (or times out), and appends it to Changes
Sequence Diagram(s)sequenceDiagram
participant LS as LaunchServices
participant Second as Second Process
participant Capture as captureLaunchURL (NSApp)
participant First as First Process (Primary)
LS->>Second: open app with URL (kAEGetURL)
Second->>Capture: start ephemeral NSApp, register kAEGetURL handler
Capture->>Capture: wait for kAEGetURL or 300ms timeout
alt URL received
Capture-->>Second: return captured URL
else timeout/no URL
Capture-->>Second: return ""
end
Second->>First: notifyFirstInstance (args + captured URL)
First->>First: OnSecondInstanceLaunch callback handles args (including URL)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.11.4)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@v3/examples/single-instance-url-scheme/build/darwin/Taskfile.yml`:
- Around line 21-27: The package task currently sets PRODUCTION: "false",
preventing the build task from using production tags/linker flags; update the
package task (the "package" block that calls task: build) to either remove the
PRODUCTION override or set PRODUCTION: "true" so that the called build task
picks up production flags and linker settings (look for the package task's deps
entry that references task: build and adjust the PRODUCTION var accordingly).
In `@v3/examples/single-instance-url-scheme/README.md`:
- Around line 92-96: Update the two fenced log blocks that currently use plain
triple backticks to specify a language (use "text") so markdownlint no longer
flags them; replace the fence markers around the log excerpts beginning with
"[first] OnSecondInstanceLaunch fired" (the block showing url-in-args? = true)
and the second similar block (the one showing url-in-args? = false) to use
```text instead of ``` so both log excerpts are explicitly marked as text.
- Around line 122-124: The README's example command calls lsregister directly
which may not exist on the user's PATH; update the README.md line that currently
shows "lsregister -f bin/single-instance-url-scheme.dev.app" to invoke
lsregister via the LaunchServices support path (i.e., use the full path to the
LaunchServices Support/lsregister binary) so the command works reliably on
macOS.
In `@v3/examples/single-instance-url-scheme/Taskfile.yml`:
- Around line 27-46: Update the trigger and trigger:force task descriptions to
reflect the fixed behavior: remove statements about the URL being dropped and
about relaying os.Args with no URL; instead state that forced second-instance
launches now capture and relay the URL (the second process relays the URL to the
first instance) and that OnSecondInstanceLaunch fires on the first instance with
url-in-args?=true; update the explanatory text in the summary/desc for the
trigger and trigger:force entries and keep the existing examples/commands (refer
to the trigger and trigger:force blocks and their cmds).
In `@v3/pkg/application/single_instance_darwin_url.go`:
- Around line 6-11: The CFLAGS for the cgo block do not enable Objective‑C
blocks, and the code uses a block literal with dispatch_after; update the cgo
flags to add -fblocks (e.g., extend the existing "#cgo CFLAGS: -x objective-c"
to include -fblocks) and add an explicit import of dispatch/dispatch.h alongside
the Foundation/Cocoa imports to ensure dispatch_after and block syntax compile
correctly.
In `@v3/release_notes.md`:
- Line 2: Update the release note line that claims NSGlassEffectView is
available on macOS 15.0+ to the correct macOS version (macOS 26) so it reads
that native NSGlassEffectView support for Liquid Glass effects requires macOS 26
with NSVisualEffectView as the fallback; locate the entry mentioning
NSGlassEffectView and NSVisualEffectView (and the PR `#4534` / author `@leaanthony`)
in v3/release_notes.md and change the version string only, leaving the rest of
the wording intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 74a71441-db44-4d40-9303-4abdab771ba4
⛔ Files ignored due to path filters (1)
v3/examples/single-instance-url-scheme/build/appicon.pngis excluded by!**/*.png
📒 Files selected for processing (13)
v3/examples/single-instance-url-scheme/README.mdv3/examples/single-instance-url-scheme/Taskfile.ymlv3/examples/single-instance-url-scheme/assets/index.htmlv3/examples/single-instance-url-scheme/build/Taskfile.ymlv3/examples/single-instance-url-scheme/build/darwin/Info.dev.plistv3/examples/single-instance-url-scheme/build/darwin/Info.plistv3/examples/single-instance-url-scheme/build/darwin/Taskfile.ymlv3/examples/single-instance-url-scheme/build/darwin/icons.icnsv3/examples/single-instance-url-scheme/main.gov3/pkg/application/single_instance.gov3/pkg/application/single_instance_darwin_url.gov3/pkg/application/single_instance_launch_url_other.gov3/release_notes.md
There was a problem hiding this comment.
Pull request overview
Fixes a macOS-specific gap in SingleInstance handling where URL-scheme launches (delivered via kAEGetURL Apple Events) could be dropped when a second process exits before NSApplication finishes launching, and adds an example + documentation demonstrating the corrected behavior.
Changes:
- Add a darwin-only
captureLaunchURL()that briefly runs anNSApplicationloop to receivekAEGetURLand return the URL. - Append any captured URL to
SecondInstanceData.Argsduring the second-instance relay (notifyFirstInstance()), aligning with Windows/Linux behavior. - Add/adjust example, docs, and release notes describing the fix and how to reproduce/verify it.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
v3/pkg/application/single_instance_darwin_url.go |
New darwin implementation to capture URL-scheme launches via Apple Events before second-instance exit. |
v3/pkg/application/single_instance_launch_url_other.go |
New non-darwin stub to keep behavior unchanged on other platforms. |
v3/pkg/application/single_instance.go |
Relay path now attempts to capture a macOS launch URL and append it to args before notifying the first instance. |
v3/examples/single-instance-url-scheme/main.go |
New example app to demonstrate/verify URL relay vs. ApplicationLaunchedWithUrl. |
v3/examples/single-instance-url-scheme/assets/index.html |
Minimal UI to display last URL-related events received by the first instance. |
v3/examples/single-instance-url-scheme/README.md |
Documentation explaining the original issue, the fix, and how to run the repro on macOS. |
v3/examples/single-instance-url-scheme/Taskfile.yml |
Helper tasks to run/package the example and trigger URL launches for testing. |
v3/examples/single-instance-url-scheme/build/darwin/Taskfile.yml |
macOS build/package/run scripting for the example app bundle. |
v3/examples/single-instance-url-scheme/build/darwin/Info.plist |
Example app bundle plist registering the custom URL scheme. |
v3/examples/single-instance-url-scheme/build/darwin/Info.dev.plist |
Dev variant plist for the example bundle identifier. |
v3/examples/single-instance-url-scheme/build/Taskfile.yml |
Shared build helper (go mod tidy) for the example. |
v3/release_notes.md |
Release note entry describing the macOS SingleInstance URL relay fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #cgo CFLAGS: -x objective-c | ||
| #cgo LDFLAGS: -framework Foundation -framework Cocoa |
There was a problem hiding this comment.
The new darwin CGO file doesn’t set a macOS deployment target in #cgo CFLAGS/LDFLAGS. Other darwin CGO files in this package consistently include -mmacosx-version-min=10.13 in both, and omitting it can change the produced binary’s min OS version and lead to inconsistent linking across files. Consider adding the same -mmacosx-version-min flags here for consistency with the rest of v3/pkg/application darwin sources.
| #cgo CFLAGS: -x objective-c | |
| #cgo LDFLAGS: -framework Foundation -framework Cocoa | |
| #cgo CFLAGS: -x objective-c -mmacosx-version-min=10.13 | |
| #cgo LDFLAGS: -mmacosx-version-min=10.13 -framework Foundation -framework Cocoa |
There was a problem hiding this comment.
Fixed — added -mmacosx-version-min=10.13 to both CFLAGS and LDFLAGS, matching the rest of the darwin files in pkg/application.
| args := os.Args | ||
| // On macOS, URL-scheme launches deliver the URL via NSAppleEventManager | ||
| // (kAEGetURL), not via argv. Capture it here before we exit so the first | ||
| // instance receives the URL in SecondInstanceData.Args — matching the | ||
| // Windows/Linux behaviour where the URL is already in argv. | ||
| if url := captureLaunchURL(); url != "" { | ||
| args = append(args, url) | ||
| } |
There was a problem hiding this comment.
notifyFirstInstance() now calls captureLaunchURL() unconditionally on macOS. When no URL Apple Event arrives, this blocks for the full timeout (currently 300ms) before notifying the first instance, which regresses the “reopen to focus existing instance” path latency for normal launches. Consider reducing the timeout and/or gating URL capture behind a cheap heuristic/config option so non-URL second-instance launches don’t pay the extra delay.
There was a problem hiding this comment.
Won't change for now. The 300 ms wait is inherent to the fix — you can't determine whether a URL Apple Event is pending without running [NSApp run], which is the exact step that was missing before. The second-instance path is rare (user explicitly opening a second window of a running app), and 300 ms to bring the existing window forward is imperceptible in practice. Making the timeout configurable is a reasonable follow-up but adds complexity for marginal benefit right now.
| // captureLaunchURL briefly runs an NSApplication event loop so that | ||
| // LaunchServices can deliver any pending kAEGetURL Apple Event (e.g. when | ||
| // this process was force-launched via "open -n URL"). | ||
| // Returns the URL string, or "" if none arrived within the timeout. | ||
| func captureLaunchURL() string { | ||
| cURL := C.CaptureLaunchURL(C.double(0.3)) |
There was a problem hiding this comment.
The URL capture timeout is currently a hard-coded literal (0.3) at the call site. It would be easier to tune and reason about (and keep in sync with docs/release notes) if this were a named constant (or derived from a shared setting) rather than a magic number.
| // captureLaunchURL briefly runs an NSApplication event loop so that | |
| // LaunchServices can deliver any pending kAEGetURL Apple Event (e.g. when | |
| // this process was force-launched via "open -n URL"). | |
| // Returns the URL string, or "" if none arrived within the timeout. | |
| func captureLaunchURL() string { | |
| cURL := C.CaptureLaunchURL(C.double(0.3)) | |
| const launchURLCaptureTimeoutSeconds = 0.3 | |
| // captureLaunchURL briefly runs an NSApplication event loop so that | |
| // LaunchServices can deliver any pending kAEGetURL Apple Event (e.g. when | |
| // this process was force-launched via "open -n URL"). | |
| // Returns the URL string, or "" if none arrived within the timeout. | |
| func captureLaunchURL() string { | |
| cURL := C.CaptureLaunchURL(C.double(launchURLCaptureTimeoutSeconds)) |
There was a problem hiding this comment.
Fixed — extracted as launchURLCaptureTimeout = 0.3 constant.
| On macOS 14/15, this routes the URL to a second process (which then hits the | ||
| SingleInstance lock and exits), dropping the URL — the bug in action. | ||
| On macOS 26+, LaunchServices sends the Apple Event to the already-running | ||
| process instead, so ApplicationLaunchedWithUrl fires correctly (no bug seen). | ||
| Use trigger:force to reproduce the bug on macOS 26+. | ||
| cmds: | ||
| - 'open "{{.URL | default "wails-single-url://hello?n=1"}}"' | ||
|
|
||
| trigger:force: | ||
| summary: 'Force a new process launch with URL (reproduces bug on macOS 26+). Usage: wails3 task trigger:force URL=wails-single-url://hello?n=1' | ||
| desc: | | ||
| Uses open -n to bypass LaunchServices reuse behaviour and force a new process. | ||
| The new process hits the SingleInstance lock, relays os.Args (no URL), and exits. | ||
| The Apple Event carrying the URL is queued for the dying process and dropped. | ||
| OnSecondInstanceLaunch fires on the first instance with url-in-args?=false. |
There was a problem hiding this comment.
The trigger task description says the URL is dropped on macOS 14/15 (“bug in action”), but with this PR applied the URL should be relayed to the first instance. Consider updating the task description to reflect the fixed behaviour (and, if useful, note that this was the pre-fix behaviour).
| On macOS 14/15, this routes the URL to a second process (which then hits the | |
| SingleInstance lock and exits), dropping the URL — the bug in action. | |
| On macOS 26+, LaunchServices sends the Apple Event to the already-running | |
| process instead, so ApplicationLaunchedWithUrl fires correctly (no bug seen). | |
| Use trigger:force to reproduce the bug on macOS 26+. | |
| cmds: | |
| - 'open "{{.URL | default "wails-single-url://hello?n=1"}}"' | |
| trigger:force: | |
| summary: 'Force a new process launch with URL (reproduces bug on macOS 26+). Usage: wails3 task trigger:force URL=wails-single-url://hello?n=1' | |
| desc: | | |
| Uses open -n to bypass LaunchServices reuse behaviour and force a new process. | |
| The new process hits the SingleInstance lock, relays os.Args (no URL), and exits. | |
| The Apple Event carrying the URL is queued for the dying process and dropped. | |
| OnSecondInstanceLaunch fires on the first instance with url-in-args?=false. | |
| Launch the custom URL against the running app. | |
| On macOS 14/15, the second process now hits the SingleInstance lock, relays the | |
| URL to the first instance, and exits, so the already-running app receives it. | |
| On macOS 26+, LaunchServices sends the Apple Event to the already-running | |
| process directly, so ApplicationLaunchedWithUrl also fires correctly there. | |
| Previously, on macOS 14/15, the URL could be dropped; use trigger:force to | |
| exercise the forced-new-process path explicitly. | |
| cmds: | |
| - 'open "{{.URL | default "wails-single-url://hello?n=1"}}"' | |
| trigger:force: | |
| summary: 'Force a new process launch with URL. Usage: wails3 task trigger:force URL=wails-single-url://hello?n=1' | |
| desc: | | |
| Uses open -n to bypass LaunchServices reuse behaviour and force a new process. | |
| The new process hits the SingleInstance lock, relays the launch/URL information | |
| to the first instance, and exits. | |
| Use this to exercise the forced-new-process path explicitly, especially on | |
| macOS 26+ where LaunchServices would normally reuse the running process. |
There was a problem hiding this comment.
Already addressed in the previous commit — trigger description now describes the fixed relay behaviour.
| The new process hits the SingleInstance lock, relays os.Args (no URL), and exits. | ||
| The Apple Event carrying the URL is queued for the dying process and dropped. | ||
| OnSecondInstanceLaunch fires on the first instance with url-in-args?=false. |
There was a problem hiding this comment.
The trigger:force description still states the Apple Event is “queued for the dying process and dropped” and that the first instance sees url-in-args?=false. With the new URL-capture relay in place, the expected outcome should be that the URL is captured and appended to SecondInstanceData.Args. Please update this description to match the post-fix behaviour (or explicitly label it as the pre-fix expectation).
| The new process hits the SingleInstance lock, relays os.Args (no URL), and exits. | |
| The Apple Event carrying the URL is queued for the dying process and dropped. | |
| OnSecondInstanceLaunch fires on the first instance with url-in-args?=false. | |
| The new process hits the SingleInstance lock, relays its launch data, and exits. | |
| With the URL-capture relay in place, the URL is captured and appended to | |
| SecondInstanceData.Args rather than being dropped with the dying process. | |
| OnSecondInstanceLaunch fires on the first instance with the URL present in args. |
There was a problem hiding this comment.
Already addressed in the previous commit — trigger:force description now reflects fixed behaviour (url-in-args?=true).
| The Apple Event carrying the URL is queued for the dying process and dropped. | ||
| OnSecondInstanceLaunch fires on the first instance with url-in-args?=false. | ||
| cmds: | ||
| - 'open -n -u "{{.URL | default "wails-single-url://hello?n=1"}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"' |
There was a problem hiding this comment.
open -n -u <url> <appbundle> is likely not doing what’s intended: -u treats subsequent arguments as URLs, so the .app path can be interpreted as a URL rather than selecting the target application. To force a new instance and route the URL to this bundle, use open -n -a <appbundle> <url> (or an equivalent -a/-b form).
| - 'open -n -u "{{.URL | default "wails-single-url://hello?n=1"}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"' | |
| - 'open -n -a "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" "{{.URL | default "wails-single-url://hello?n=1"}}"' |
There was a problem hiding this comment.
Good catch — -u is wrong here. Fixed to open -n -a "${APP}" "${URL}". Retested on macOS 26 Apple Silicon: second instance spawns, captures the URL, and the first instance logs url-in-args?=true.
- Add -fblocks to CGo CFLAGS and explicit dispatch/dispatch.h import - Fix package task PRODUCTION flag (false→true) - Use ```text fences for log blocks in README - Use full lsregister path in README - Update trigger/trigger:force descriptions to reflect fixed behaviour - Fix release_notes macOS version: 15.0+ → 26+ for NSGlassEffectView - Remove #5089 fix entry from release_notes (changelog is automated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add -mmacosx-version-min=10.13 to CGo CFLAGS/LDFLAGS (consistency with rest of pkg/application) - Extract launchURLCaptureTimeout named constant (replaces magic 0.3 literal) - Fix trigger:force command: open -n -u → open -n -a (Copilot correctly flagged -u as wrong flag) Retested after changes: open -n -a correctly forces a new process and URL relay works (url-in-args?=true confirmed on macOS 26 Apple Silicon). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes #5089
The problem
When
SingleInstanceis active and a URL-scheme launch triggers a second process on macOS (e.g.open -n wails-single-url://hello), the URL is silently dropped. NeitherOnSecondInstanceLaunchnorApplicationLaunchedWithUrlreceives it.Why: On macOS, LaunchServices delivers URL-scheme launches via a
kAEGetURLApple Event — not viaos.Argsas on Windows/Linux. This event is only dispatched after[NSApp run]callsfinishLaunching. The second instance was callingnotifyFirstInstance()→os.Exit()before any of that infrastructure ran, so the event was queued by the OS but never delivered.The fix
Added
v3/pkg/application/single_instance_darwin_url.gowithcaptureLaunchURL():[NSApplication sharedApplication]withNSApplicationActivationPolicyProhibited(no dock icon — this is a short-lived background process)kAEGetURLhandler before[NSApp run][NSApp run]— this triggersfinishLaunching, which signals LaunchServices the process is ready to receive Apple Events""on timeoutnotifyFirstInstance()insingle_instance.gocallscaptureLaunchURL()on darwin and appends any captured URL toSecondInstanceData.Argsbefore sending the notification to the first instance. This matches Windows/Linux behaviour where the URL is already inos.Args.A stub
single_instance_launch_url_other.go(!darwin || ios || server) returns""— no behaviour change on other platforms.ApplicationLaunchedWithUrlis not fired on the second-instance relay path, consistent with Windows/Linux.Evidence — test log (macOS 26.0, Apple Silicon)
Second instance exit time: ~120–160 ms on URL capture; ~420 ms on timeout (300 ms + overhead).
Files changed
v3/pkg/application/single_instance_darwin_url.gov3/pkg/application/single_instance_launch_url_other.gov3/pkg/application/single_instance.gocaptureLaunchURL()innotifyFirstInstance()v3/examples/single-instance-url-scheme/README.mdv3/release_notes.mdOut of scope
Adding
LSMultipleInstancesProhibitedto the generatedInfo.plist(prevents macOS 14/15 from spawning a second process entirely). Separate ticket.Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Chores