Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions v3/examples/single-instance-url-scheme/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# single-instance-url-scheme — fix for issue #5089

Demonstrates (and formerly reproduced) [wailsapp/wails#5089](https://github.com/wailsapp/wails/issues/5089):
combining `SingleInstance` with a custom URL scheme on macOS.

## What the bug was

On macOS, URL-scheme launches do not place the URL in `os.Args`.
LaunchServices delivers the URL via an Apple Event (`kAEGetURL`) that is
dispatched by the target process's `NSAppleEventManager` — which is only
wired up once `NSApplication.run` is executing.

When a second instance hits the flock in `newSingleInstanceManager` it
immediately called `notifyFirstInstance()` and `os.Exit`. The Apple Event
handler was never installed, so the URL was discarded. The payload relayed
to the first instance (`SecondInstanceData{Args, WorkingDir, …}`) only
contained what was in `os.Args`, which on macOS does not include the URL.

On Windows and Linux the URL is passed through `argv`, so it surfaces
naturally as `SecondInstanceData.Args[1]`. macOS was the odd one out.

## The fix

`v3/pkg/application/single_instance_darwin_url.go` adds a `captureLaunchURL()`
helper that:

1. Creates `[NSApplication sharedApplication]` with `NSApplicationActivationPolicyProhibited` (no dock icon).
2. Registers a `kAEGetURL` Apple Event handler **before** calling `[NSApp run]`.
3. Calls `[NSApp run]` — which triggers `finishLaunching`, signalling to
LaunchServices that this process is ready to receive Apple Events.
4. Stops the run loop immediately when the URL event arrives (or after a
300 ms safety-net timeout if no event arrives).
5. Returns the captured URL (or `""` on timeout).

`notifyFirstInstance()` (in `single_instance.go`) calls `captureLaunchURL()`
on darwin and appends the URL to `SecondInstanceData.Args` before notifying
the first instance, matching the Windows/Linux behaviour.

`ApplicationLaunchedWithUrl` is **not** fired on the second-instance relay
path, consistent with Windows and Linux behaviour.

## Running the example

Requirements: macOS, Go, `wails3` CLI (`task` runner), Xcode command-line
tools. Uses `codesign --sign -` (ad-hoc) so no certificate needed.

```sh
cd v3/examples/single-instance-url-scheme

# 1. Build + package a dev .app bundle and run it. This also registers
# the bundle's CFBundleURLTypes with LaunchServices so the custom
# scheme is routed to the app.
wails3 task run

# In a separate terminal, tail the log the app writes:
tail -f /tmp/wails-single-instance-url.log
```

With the first instance window visible, trigger a URL-scheme launch:

```sh
# Either via the helper task:
wails3 task trigger URL='wails-single-url://hello?n=1'

# Or directly:
open 'wails-single-url://hello?n=1'
```

### macOS version behaviour differences

**macOS 14 / 15:** `open 'wails-single-url://...'` causes LaunchServices to
spawn a second process (because `LSMultipleInstancesProhibited` is not set).
That second process detects the flock, captures the URL via the fix, and
relays it to the first instance.

**macOS 26+ (observed on 26.0/25A354, Apple Silicon):** `open 'wails-single-url://…'`
without `-n` routes the Apple Event directly to the running first instance
(`ApplicationLaunchedWithUrl` fires). No second process is spawned.

To trigger the second-instance relay path on macOS 26+, use `trigger:force`
which forces a new process via `open -n`:

```sh
wails3 task trigger:force URL='wails-single-url://hello?n=1'
```

### Fixed behaviour

After applying the fix, triggered via `trigger:force` (or via plain `trigger`
on macOS 14/15), the first instance log shows:

```text
[first] OnSecondInstanceLaunch fired
[first] Args = [.../single-instance-url-scheme wails-single-url://hello?n=1]
[first] url-in-args? = true (url="wails-single-url://hello?n=1")
```

`ApplicationLaunchedWithUrl` does **not** fire on either instance for the
second-instance relay path.

Timing (measured on macOS 26, Apple Silicon):
- URL captured: second instance exits in **~120–160 ms** (early-exit on event arrival).
- No URL (e.g. `open -n app.app`): second instance exits in **~320–430 ms** (300 ms timeout).

## Unfixed behaviour (pre-PR, for historical reference)

Triggered via `trigger:force` on the unfixed branch, the first instance logged:

```text
[first] OnSecondInstanceLaunch fired
[first] Args = [.../single-instance-url-scheme]
[first] url-in-args? = false (url="")
```

`ApplicationLaunchedWithUrl` did **not** fire on either instance.

## Notes / gotchas for testing

- Running the raw binary (`go run .`) will **not** exercise the bug path.
macOS only routes custom URL schemes to apps launched from a `.app`
bundle registered with LaunchServices. Use `wails3 task run`.
- If `open 'wails-single-url://…'` launches a different app, the scheme
is claimed by a previously-registered bundle. Re-register this one:
`/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f bin/single-instance-url-scheme.dev.app`.
- Fresh launches (first instance) already go through the
`NSAppleEventManager` path and work correctly; the fix is specific to
the second-instance relay.
52 changes: 52 additions & 0 deletions v3/examples/single-instance-url-scheme/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
version: '3'

includes:
common: ./build/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml

vars:
APP_NAME: "single-instance-url-scheme"
BIN_DIR: "bin"

tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"

package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"

run:
summary: Runs the application from a dev .app bundle (required so macOS routes URL scheme to it)
cmds:
- task: "{{OS}}:run"

trigger:
summary: 'Launch the custom URL. Usage: wails3 task trigger URL=wails-single-url://hello?n=1'
desc: |
On macOS 14/15, routes the URL to a second process which hits the SingleInstance
lock, captures the URL via the kAEGetURL Apple Event, and relays it to the first
instance via SecondInstanceData.Args before exiting.
On macOS 26+, LaunchServices sends the Apple Event directly to the running first
instance, so ApplicationLaunchedWithUrl fires (no second process spawned).
Use trigger:force to exercise the second-instance relay path on macOS 26+.
cmds:
- 'open "{{.URL | default "wails-single-url://hello?n=1"}}"'

trigger:force:
summary: 'Force a new process launch with URL (exercises second-instance relay). 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, captures the URL via the kAEGetURL
Apple Event, and relays it to the first instance via SecondInstanceData.Args.
OnSecondInstanceLaunch fires on the first instance with url-in-args?=true.
cmds:
- 'open -n -a "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" "{{.URL | default "wails-single-url://hello?n=1"}}"'

tail:
summary: Tail the repro logfile written by main.go
cmds:
- tail -f /tmp/wails-single-instance-url.log
48 changes: 48 additions & 0 deletions v3/examples/single-instance-url-scheme/assets/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Single Instance URL Scheme Repro</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; margin: 2rem; background: #1b2636; color: #eee; }
h1 { margin-top: 0; }
.row { margin: 0.5rem 0; padding: 0.75rem 1rem; background: #223043; border-radius: 6px; }
.label { color: #8aa4c8; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
pre { margin: 0.25rem 0 0; white-space: pre-wrap; word-break: break-all; }
.missing { color: #ff6b6b; }
.ok { color: #7bd88f; }
</style>
</head>
<body>
<h1>wails#5089 repro</h1>
<p>Launch a custom-scheme URL from Terminal while this window is open:</p>
<pre>open 'wails-single-url://hello?n=1'</pre>

<div class="row">
<div class="label">Last <code>ApplicationLaunchedWithUrl</code></div>
<pre id="evt-url" class="missing">(never fired)</pre>
</div>

<div class="row">
<div class="label">Last <code>OnSecondInstanceLaunch</code></div>
<pre id="evt-si" class="missing">(never fired)</pre>
</div>

<script type="module">
import { Events } from "/wails/runtime.js";

Events.On("launchedWithUrl", (ev) => {
const el = document.getElementById("evt-url");
el.className = "ok";
el.textContent = JSON.stringify(ev.data, null, 2);
});

Events.On("secondInstance", (ev) => {
const el = document.getElementById("evt-si");
const data = ev.data;
el.className = data.found ? "ok" : "missing";
el.textContent = JSON.stringify(data, null, 2);
});
</script>
</body>
</html>
7 changes: 7 additions & 0 deletions v3/examples/single-instance-url-scheme/build/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3'

tasks:
go:mod:tidy:
internal: true
cmds:
- go mod tidy
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions v3/examples/single-instance-url-scheme/build/darwin/Info.dev.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Single Instance URL Scheme Repro (dev)</string>
<key>CFBundleExecutable</key>
<string>single-instance-url-scheme</string>
<key>CFBundleIdentifier</key>
<string>com.wails.example.single-instance-url-scheme.dev</string>
<key>CFBundleVersion</key>
<string>0.0.1</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.wails.example.single-instance-url-scheme.dev</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wails-single-url</string>
</array>
</dict>
</array>
</dict>
</plist>
34 changes: 34 additions & 0 deletions v3/examples/single-instance-url-scheme/build/darwin/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Single Instance URL Scheme Repro</string>
<key>CFBundleExecutable</key>
<string>single-instance-url-scheme</string>
<key>CFBundleIdentifier</key>
<string>com.wails.example.single-instance-url-scheme</string>
<key>CFBundleVersion</key>
<string>0.0.1</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.wails.example.single-instance-url-scheme</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wails-single-url</string>
</array>
</dict>
</array>
</dict>
</plist>
49 changes: 49 additions & 0 deletions v3/examples/single-instance-url-scheme/build/darwin/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
version: '3'

tasks:
build:
summary: Builds the macOS binary
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
PRODUCTION: '{{.PRODUCTION | default "false"}}'

package:
summary: Packages the build into an `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- task: create:app:bundle

create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/darwin/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.app

run:
cmds:
- task: build
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS
- cp build/darwin/Info.dev.plist {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.dev.app
# Register the .app with LaunchServices so the custom URL scheme is recognised.
- /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'
Binary file not shown.
Loading
Loading