Skip to content

Commit 411f348

Browse files
midagedevclaude
andauthored
fix(desktop): Windows applied the first-launch deep link twice, and the first one was too early to work (GDK-293) (#34)
`desktop/main.go` applied argv whenever `runtime.GOOS != "darwin"`. The comment right above it explained the hazard correctly — apply argv on a platform that also emits the event and you navigate twice — and then put Windows on the wrong side of it. The pinned wails (v3.0.0-beta.6) emits ApplicationLaunchedWithUrl on Windows when `len(os.Args) == 2` and the argument contains "://", which is exactly the shape the CLI's own launcher produces. Confirmed on real hardware before this change: Windows 11 Home, WebView2 151.0.4129.86, a probe build logging both paths. PROBE293 event-path applying deep link "gadak://view?issue=NMB-1" deep link →/#/?issue=NMB-1 [WebView2] Environment created successfully wails runtime ready PROBE293 argv-path applying deep link "gadak://view?issue=NMB-1" deep link →/#/?issue=NMB-1 The ordering in that log is why "pick one of the two" would have been the wrong fix: the event path runs *before* the webview environment exists, so the first application was already a no-op and the one that reached the screen was the second. Picking the event without an ordering contract would have picked the dead one. So there are two changes, not one. `coldStartDecisionFor(goos, args)` is now the single owner of where a cold-start URL comes from, and exactly one source is armed per platform and argv shape — darwin defers to the event, Linux always reads argv because GTK4 never emits it, and Windows defers only when wails will actually emit, falling back to argv for every other argv shape. There is no `!= darwin` left; that negation was the bug, because it lumped two platforms with opposite behaviour into one branch. And `coldStartGate` holds anything offered before WindowRuntimeReady in a one-slot queue and flushes it there, so nothing is handed to a webview that does not exist yet. Double application is now structurally impossible rather than avoided by care: `ApplyArgv` and `DeferToEvent` are mutually exclusive, and the event handler returns early unless it is the armed source. `OnSecondInstanceLaunch` is untouched — that path was always correct — and the macOS reopen case still works, because the event arrives after ready and the gate applies it immediately. Two comments corrected. The one on ApplicationShouldHandleReopen used to analogise it to ApplicationLaunchedWithUrl ("never fires off darwin — same as"), which is the same false premise; it now says the opposite explicitly. FAIL-first, reproduced by the lead by restoring the `!= darwin` rule under the new table: the Windows launcher row comes back as `{ApplyArgv:true DeferToEvent:true}` — both sources armed — against a want of `{ApplyArgv:false DeferToEvent:true}`, plus two more Windows rows. The test pins the decision, not the rendering; no runtime event ordering is asserted, because three rounds have just been spent on timing-sensitive tests. Debuggability: the probe build is no longer needed to answer "which source supplied this URL" — `deep link source=argv|event` is logged beside the existing `deep link →` line. Gates, lead-run in desktop/: build, vet, `go test ./... -count=1` ok (4.6s). Cross-compile windows/amd64 and darwin/arm64 both build. linux/amd64 with CGO_ENABLED=0 fails on wails' own menu_linux.go, which is the pre-existing reason ci.yml documents, not this change. Co-authored-by: midagedev <midagedev@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 43c2f34 commit 411f348

2 files changed

Lines changed: 240 additions & 10 deletions

File tree

desktop/coldstart_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import "testing"
4+
5+
// The launcher that broke (cmd/gadak/views.go startWindowsDesktopImpl) starts
6+
// the exe with exactly one argument — the gadak:// URL. wails v3.0.0-beta.6
7+
// treats that shape as ApplicationLaunchedWithUrl on Windows.
8+
const launcherURL = "gadak://view?issue=NMB-1"
9+
10+
func TestColdStartDecisionFor(t *testing.T) {
11+
for _, tc := range []struct {
12+
name string
13+
goos string
14+
args []string
15+
wantApplyArgv bool
16+
wantDeferToEvent bool
17+
}{
18+
{
19+
name: "windows launcher one-arg (GDK-293)",
20+
goos: "windows",
21+
args: []string{"gadak-desktop.exe", launcherURL},
22+
wantApplyArgv: false,
23+
wantDeferToEvent: true,
24+
},
25+
{
26+
name: "windows no extra args — argv fallback is a no-op",
27+
goos: "windows",
28+
args: []string{"gadak-desktop.exe"},
29+
wantApplyArgv: true,
30+
wantDeferToEvent: false,
31+
},
32+
{
33+
name: "windows extra args — wails ignores, argv owns the URL",
34+
goos: "windows",
35+
args: []string{"gadak-desktop.exe", "--flag", launcherURL},
36+
wantApplyArgv: true,
37+
wantDeferToEvent: false,
38+
},
39+
{
40+
name: "windows two extra args including the URL",
41+
goos: "windows",
42+
args: []string{"gadak-desktop.exe", launcherURL, "other"},
43+
wantApplyArgv: true,
44+
wantDeferToEvent: false,
45+
},
46+
{
47+
name: "windows one arg without :// — wails does not emit",
48+
goos: "windows",
49+
args: []string{"gadak-desktop.exe", "not-a-url"},
50+
wantApplyArgv: true,
51+
wantDeferToEvent: false,
52+
},
53+
{
54+
name: "darwin one-arg is still event-only",
55+
goos: "darwin",
56+
args: []string{"gadak-desktop", launcherURL},
57+
wantApplyArgv: false,
58+
wantDeferToEvent: true,
59+
},
60+
{
61+
name: "darwin no args is event-only",
62+
goos: "darwin",
63+
args: []string{"gadak-desktop"},
64+
wantApplyArgv: false,
65+
wantDeferToEvent: true,
66+
},
67+
{
68+
name: "linux one-arg is argv (GTK4 does not emit the event)",
69+
goos: "linux",
70+
args: []string{"gadak-desktop", launcherURL},
71+
wantApplyArgv: true,
72+
wantDeferToEvent: false,
73+
},
74+
{
75+
name: "linux no args is argv",
76+
goos: "linux",
77+
args: []string{"gadak-desktop"},
78+
wantApplyArgv: true,
79+
wantDeferToEvent: false,
80+
},
81+
} {
82+
t.Run(tc.name, func(t *testing.T) {
83+
got := coldStartDecisionFor(tc.goos, tc.args)
84+
if got.ApplyArgv != tc.wantApplyArgv || got.DeferToEvent != tc.wantDeferToEvent {
85+
t.Fatalf("coldStartDecisionFor(%q, %q) = {ApplyArgv:%v DeferToEvent:%v}, want {ApplyArgv:%v DeferToEvent:%v}",
86+
tc.goos, tc.args, got.ApplyArgv, got.DeferToEvent, tc.wantApplyArgv, tc.wantDeferToEvent)
87+
}
88+
})
89+
}
90+
}
91+
92+
// TestColdStartGate is a state machine, not a runtime event-order test: it
93+
// pins "nothing is applied before markReady" without opening a window.
94+
func TestColdStartGate(t *testing.T) {
95+
type applied struct{ raw, src string }
96+
var got []applied
97+
g := coldStartGate{apply: func(raw, source string) {
98+
got = append(got, applied{raw, source})
99+
}}
100+
101+
g.offer("gadak://view?issue=NMB-1", "event")
102+
if len(got) != 0 {
103+
t.Fatalf("offer before ready applied %v", got)
104+
}
105+
106+
g.offer("gadak://view?issue=NMB-2", "event")
107+
g.markReady()
108+
if len(got) != 1 || got[0] != (applied{"gadak://view?issue=NMB-1", "event"}) {
109+
t.Fatalf("markReady flush = %v, want first-offer only", got)
110+
}
111+
112+
g.offer("gadak://view?issue=NMB-3", "argv")
113+
if len(got) != 2 || got[1] != (applied{"gadak://view?issue=NMB-3", "argv"}) {
114+
t.Fatalf("offer after ready = %v, want immediate apply", got)
115+
}
116+
117+
g.offer("", "event")
118+
if len(got) != 2 {
119+
t.Fatalf("empty offer applied %v", got)
120+
}
121+
}

desktop/main.go

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"path"
2222
"runtime"
2323
"strings"
24+
"sync"
2425
"unsafe"
2526

2627
"github.com/wailsapp/wails/v3/pkg/application"
@@ -61,6 +62,91 @@ func main() {
6162
}
6263
}
6364

65+
// coldStartDecision is the single owner of how a first-launch gadak://
66+
// reaches this process: from argv, or from ApplicationLaunchedWithUrl.
67+
type coldStartDecision struct {
68+
ApplyArgv bool
69+
DeferToEvent bool
70+
}
71+
72+
// coldStartDecisionFor reports the cold-start URL source for this GOOS and
73+
// the process argv (full os.Args, including argv[0]).
74+
//
75+
// - darwin: event only. LaunchServices delivers the URL as an Apple Event;
76+
// applying argv as well would navigate twice.
77+
// - windows: event when wails will emit ApplicationLaunchedWithUrl — that
78+
// is len(args)==2 and args[1] contains "://" (wails v3.0.0-beta.6
79+
// pkg/application/application_windows.go:159-162). Every other argv
80+
// shape is ignored by wails, so argv is the fallback.
81+
// - linux (and anything else): argv. GTK4 run() in the same wails pin
82+
// (application_linux.go:89-99) does not emit the event.
83+
func coldStartDecisionFor(goos string, args []string) coldStartDecision {
84+
switch goos {
85+
case "darwin":
86+
return coldStartDecision{ApplyArgv: false, DeferToEvent: true}
87+
case "windows":
88+
if wailsEmitsLaunchURL(args) {
89+
return coldStartDecision{ApplyArgv: false, DeferToEvent: true}
90+
}
91+
return coldStartDecision{ApplyArgv: true, DeferToEvent: false}
92+
default:
93+
return coldStartDecision{ApplyArgv: true, DeferToEvent: false}
94+
}
95+
}
96+
97+
// wailsEmitsLaunchURL is the argv shape wails v3.0.0-beta.6 special-cases
98+
// on Windows (application_windows.go:159-162). GTK3 has the same check;
99+
// GTK4, which this pin compiles, does not.
100+
func wailsEmitsLaunchURL(args []string) bool {
101+
return len(args) == 2 && strings.Contains(args[1], "://")
102+
}
103+
104+
// coldStartGate applies a cold-start URL only after WindowRuntimeReady.
105+
// Bound: one slot. An offer that arrives before ready is queued; a second
106+
// offer before ready is dropped (first wins). After ready, offer applies
107+
// immediately. A URL that is still queued when markReady runs is flushed
108+
// then. There is no drop-on-timeout — the window either becomes ready or
109+
// the process exits.
110+
type coldStartGate struct {
111+
mu sync.Mutex
112+
ready bool
113+
pendingRaw string
114+
pendingSrc string
115+
apply func(raw, source string)
116+
}
117+
118+
func (g *coldStartGate) offer(raw, source string) {
119+
if raw == "" {
120+
return
121+
}
122+
g.mu.Lock()
123+
if !g.ready {
124+
if g.pendingRaw == "" {
125+
g.pendingRaw = raw
126+
g.pendingSrc = source
127+
}
128+
g.mu.Unlock()
129+
return
130+
}
131+
apply := g.apply
132+
g.mu.Unlock()
133+
if apply != nil {
134+
apply(raw, source)
135+
}
136+
}
137+
138+
func (g *coldStartGate) markReady() {
139+
g.mu.Lock()
140+
g.ready = true
141+
raw, src := g.pendingRaw, g.pendingSrc
142+
g.pendingRaw, g.pendingSrc = "", ""
143+
apply := g.apply
144+
g.mu.Unlock()
145+
if raw != "" && apply != nil {
146+
apply(raw, src)
147+
}
148+
}
149+
64150
func run() error {
65151
cfg, err := config.Load()
66152
if err != nil {
@@ -225,15 +311,32 @@ func run() error {
225311
}
226312
app.Menu.Set(appMenu)
227313

314+
decision := coldStartDecisionFor(runtime.GOOS, os.Args)
315+
var gate coldStartGate
316+
gate.apply = func(raw, source string) {
317+
if applyDeepLink == nil {
318+
return
319+
}
320+
log.Printf("deep link source=%s", source)
321+
applyDeepLink(raw)
322+
}
323+
228324
window = app.Window.NewWithOptions(mainWindowOptions())
229325
window.OnWindowEvent(events.Common.WindowRuntimeReady, func(*application.WindowEvent) {
230326
log.Print("wails runtime ready — --wails-draggable listeners are attached")
231-
// Windows (and Linux) deliver a first-launch gadak:// as argv. macOS
232-
// uses ApplicationLaunchedWithUrl for that; applying argv there too
233-
// would navigate twice.
234-
if runtime.GOOS != "darwin" && applyDeepLink != nil {
327+
// Cold-start URL source is coldStartDecisionFor, not "!= darwin".
328+
// Linux always reads argv (GTK4 does not emit ApplicationLaunchedWithUrl).
329+
// Windows reads argv only when wails will not emit the event
330+
// (len(os.Args) != 2 or the single arg has no "://"). macOS never
331+
// reads argv. Nothing is handed to the webview until this event:
332+
// an ApplicationLaunchedWithUrl that arrived earlier sits in gate
333+
// (one slot, first offer wins) and is flushed here. Argv, when this
334+
// process owns it, is offered after the flush so it cannot race a
335+
// pending event.
336+
gate.markReady()
337+
if decision.ApplyArgv {
235338
if raw := firstDeepLinkArg(os.Args[1:]); raw != "" {
236-
applyDeepLink(raw)
339+
gate.offer(raw, "argv")
237340
}
238341
}
239342
})
@@ -242,17 +345,23 @@ func run() error {
242345
window.SetURL(path)
243346
raiseWindow(window)
244347
})
245-
// The other delivery route: the app was already running, so the Apple
246-
// Event reaches this process and wails emits it as an application event.
348+
// ApplicationLaunchedWithUrl: macOS Apple Event (first launch and
349+
// same-process reopen) and Windows when wails sees a single argument
350+
// containing "://". Linux GTK4 never emits it. Offers go through the
351+
// ready gate so a URL that arrives before the webview exists is queued,
352+
// not applied.
247353
app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl,
248354
func(e *application.ApplicationEvent) {
249-
applyDeepLink(e.Context().URL())
355+
if !decision.DeferToEvent {
356+
return
357+
}
358+
gate.offer(e.Context().URL(), "event")
250359
})
251360
// Dock click (minimised or no visible window). wails' own handler only
252361
// Show()s when HasVisibleWindows is false; a miniaturised window is still
253362
// "visible", so Restore+Focus is the same raise the second-instance path
254-
// already uses. The event is defined on every GOOS and never fires off
255-
// darwin — same as ApplicationLaunchedWithUrl.
363+
// already uses. ApplicationShouldHandleReopen is macOS-only; do not
364+
// analogise it to ApplicationLaunchedWithUrl, which Windows does emit.
256365
app.Event.OnApplicationEvent(events.Mac.ApplicationShouldHandleReopen,
257366
func(*application.ApplicationEvent) {
258367
raiseWindow(window)

0 commit comments

Comments
 (0)