detached a3a4b09

fix(navigation): bind external launch prompts to a token and name the requester

Each prompt carries a unique token and the WebView identity (id and generation), so an old answer cannot launch after the pane is rebound or re-prompted. The confirmation names the host of the page that requested the launch.
commit
a3a4b0976bf247db16889702d6e2cb5e68ba4d72
author
brice · · 2026-10-15 07:29 UTC
parent
987a989
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +144 -36
M internal/ui/app.go +3 -3
@@ -3605,12 +3605,12 @@ func (a *App) wireKeyboardActions() {
36053605
36063606 // externalLaunchConfirmer is implemented by the window permission dialog.
36073607 type externalLaunchConfirmer interface {
3608- ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool))
3608+ ShowExternalLaunchConfirm(ctx context.Context, origin, uri string, callback func(allowed bool))
36093609 }
36103610
36113611 // confirmExternalLaunch asks, in the window owning paneID, whether uri may be
36123612 // opened by an external application. Without a window it denies.
3613-func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond func(allowed bool)) {
3613+func (a *App) confirmExternalLaunch(paneID entity.PaneID, origin, uri string, respond func(allowed bool)) {
36143614 ctx := context.Background()
36153615 if a.deps != nil && a.deps.Ctx != nil {
36163616 ctx = a.deps.Ctx
@@ -3620,7 +3620,7 @@ func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond fu
36203620 respond(false)
36213621 return
36223622 }
3623- confirmer.ShowExternalLaunchConfirm(ctx, uri, respond)
3623+ confirmer.ShowExternalLaunchConfirm(ctx, origin, uri, respond)
36243624 }
36253625
36263626 // permissionDialogForPane returns the permission dialog of the window owning the
@@ -3605,12 +3605,12 @@ func (a *App) wireKeyboardActions() {
3605 3605
3606 // externalLaunchConfirmer is implemented by the window permission dialog.3606 // externalLaunchConfirmer is implemented by the window permission dialog.
3607 type externalLaunchConfirmer interface {3607 type externalLaunchConfirmer interface {
3608- ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool))3608+ ShowExternalLaunchConfirm(ctx context.Context, origin, uri string, callback func(allowed bool))
3609 }3609 }
3610 3610
3611 // confirmExternalLaunch asks, in the window owning paneID, whether uri may be3611 // confirmExternalLaunch asks, in the window owning paneID, whether uri may be
3612 // opened by an external application. Without a window it denies.3612 // opened by an external application. Without a window it denies.
3613-func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond func(allowed bool)) {3613+func (a *App) confirmExternalLaunch(paneID entity.PaneID, origin, uri string, respond func(allowed bool)) {
3614 ctx := context.Background()3614 ctx := context.Background()
3615 if a.deps != nil && a.deps.Ctx != nil {3615 if a.deps != nil && a.deps.Ctx != nil {
3616 ctx = a.deps.Ctx3616 ctx = a.deps.Ctx
@@ -3620,7 +3620,7 @@ func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond fu
3620 respond(false)3620 respond(false)
3621 return3621 return
3622 }3622 }
3623- confirmer.ShowExternalLaunchConfirm(ctx, uri, respond)3623+ confirmer.ShowExternalLaunchConfirm(ctx, origin, uri, respond)
3624 }3624 }
3625 3625
3626 // permissionDialogForPane returns the permission dialog of the window owning the3626 // permissionDialogForPane returns the permission dialog of the window owning the
M internal/ui/coordinator/content/coordinator.go +9 -6
@@ -136,12 +136,15 @@ type Coordinator struct {
136136 onLaunchExternalURL func(uri string)
137137 // Callback asking the user to confirm an external scheme launch for a pane.
138138 // respond must be called exactly once on the GTK thread.
139- onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))
139+ // origin is the host of the requesting page, empty when unknown.
140+ onConfirmExternalLaunch func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))
140141
141- // externalPromptMu guards externalPrompts: panes with an outstanding
142- // external-launch prompt, mapped to the WebView that asked.
143- externalPromptMu sync.Mutex
144- externalPrompts map[entity.PaneID]port.WebView
142+ // externalPromptMu guards the external-launch prompt state: outstanding
143+ // prompts per pane, the token sequence, and the last web host per pane.
144+ externalPromptMu sync.Mutex
145+ externalPrompts map[entity.PaneID]externalPrompt
146+ externalPromptSeq uint64
147+ externalPromptOrigins map[entity.PaneID]string
145148 }
146149
147150 type pendingThemeUpdate struct {
@@ -317,7 +320,7 @@ func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
317320
318321 // SetOnConfirmExternalLaunch sets the callback that asks the user whether an
319322 // external URL scheme reached by page navigation may be opened.
320-func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, uri string, respond func(allowed bool))) {
323+func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))) {
321324 c.onConfirmExternalLaunch = fn
322325 }
323326
@@ -136,12 +136,15 @@ type Coordinator struct {
136 onLaunchExternalURL func(uri string)136 onLaunchExternalURL func(uri string)
137 // Callback asking the user to confirm an external scheme launch for a pane.137 // Callback asking the user to confirm an external scheme launch for a pane.
138 // respond must be called exactly once on the GTK thread.138 // respond must be called exactly once on the GTK thread.
139- onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))139+ // origin is the host of the requesting page, empty when unknown.
140+ onConfirmExternalLaunch func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))
140 141
141- // externalPromptMu guards externalPrompts: panes with an outstanding142+ // externalPromptMu guards the external-launch prompt state: outstanding
142- // external-launch prompt, mapped to the WebView that asked.143+ // prompts per pane, the token sequence, and the last web host per pane.
143- externalPromptMu sync.Mutex144+ externalPromptMu sync.Mutex
144- externalPrompts map[entity.PaneID]port.WebView145+ externalPrompts map[entity.PaneID]externalPrompt
146+ externalPromptSeq uint64
147+ externalPromptOrigins map[entity.PaneID]string
145 }148 }
146 149
147 type pendingThemeUpdate struct {150 type pendingThemeUpdate struct {
@@ -317,7 +320,7 @@ func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
317 320
318 // SetOnConfirmExternalLaunch sets the callback that asks the user whether an321 // SetOnConfirmExternalLaunch sets the callback that asks the user whether an
319 // external URL scheme reached by page navigation may be opened.322 // external URL scheme reached by page navigation may be opened.
320-func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, uri string, respond func(allowed bool))) {323+func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))) {
321 c.onConfirmExternalLaunch = fn324 c.onConfirmExternalLaunch = fn
322 }325 }
323 326
M internal/ui/coordinator/content/navigation.go +38 -10
@@ -387,13 +387,20 @@ func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
387387 }
388388 }
389389
390+// externalPrompt is a pane's outstanding external-launch confirmation.
391+type externalPrompt struct {
392+ token uint64
393+ identity webViewIdentity
394+}
395+
390396 // promptExternalLaunch asks the user whether the external URI may be handed to
391397 // the system handler. A pane has at most one outstanding prompt: further
392398 // external URIs from that pane are dropped until it is answered, so a page
393399 // cannot flood the window's prompt queue.
394400 func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
395401 log := logging.FromContext(ctx)
396- if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
402+ identity, ok := identityForWebView(wv)
403+ if !ok || c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
397404 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
398405 return
399406 }
@@ -404,22 +411,26 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
404411 return
405412 }
406413 if c.externalPrompts == nil {
407- c.externalPrompts = make(map[entity.PaneID]port.WebView)
414+ c.externalPrompts = make(map[entity.PaneID]externalPrompt)
408415 }
409- c.externalPrompts[paneID] = wv
416+ c.externalPromptSeq++
417+ token := c.externalPromptSeq
418+ c.externalPrompts[paneID] = externalPrompt{token: token, identity: identity}
419+ origin := c.externalPromptOrigins[paneID]
410420 c.externalPromptMu.Unlock()
411421
412- c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {
422+ c.onConfirmExternalLaunch(paneID, origin, uri, func(allowed bool) {
413423 c.externalPromptMu.Lock()
414- owner, pending := c.externalPrompts[paneID]
415- // The prompt is stale when its pane was released (or rebound to
416- // another WebView) while it was queued: never launch for it.
417- current := pending && owner == wv
424+ prompt, pending := c.externalPrompts[paneID]
425+ // The prompt is stale when its pane was released, rebound, or
426+ // re-prompted while it was queued: never launch for it.
427+ current := pending && prompt.token == token
418428 if current {
419429 delete(c.externalPrompts, paneID)
420430 }
421431 c.externalPromptMu.Unlock()
422- current = current && c.GetWebView(paneID) == wv
432+ bound, ok := identityForWebView(c.GetWebView(paneID))
433+ current = current && ok && bound == identity
423434
424435 log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
425436 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
@@ -430,11 +441,27 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
430441 })
431442 }
432443
444+// rememberExternalPromptOrigin records the host of the last web page shown in
445+// the pane, so a later external-launch prompt can name the requesting site.
446+func (c *Coordinator) rememberExternalPromptOrigin(paneID entity.PaneID, uri string) {
447+ host := logging.SafeURLHost(uri)
448+ if host == "" {
449+ return
450+ }
451+ c.externalPromptMu.Lock()
452+ if c.externalPromptOrigins == nil {
453+ c.externalPromptOrigins = make(map[entity.PaneID]string)
454+ }
455+ c.externalPromptOrigins[paneID] = host
456+ c.externalPromptMu.Unlock()
457+}
458+
433459 // clearExternalPrompt invalidates the pane's outstanding external-launch
434-// prompt so a late answer cannot launch anything.
460+// prompt so a late answer cannot launch anything, and forgets its origin.
435461 func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
436462 c.externalPromptMu.Lock()
437463 delete(c.externalPrompts, paneID)
464+ delete(c.externalPromptOrigins, paneID)
438465 c.externalPromptMu.Unlock()
439466 }
440467
@@ -467,6 +494,7 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
467494 return
468495 }
469496
497+ c.rememberExternalPromptOrigin(paneID, uri)
470498 if !wv.IsLoading() {
471499 c.onSPANavigation(ctx, paneID, uri)
472500 }
@@ -387,13 +387,20 @@ func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
387 }387 }
388 }388 }
389 389
390+// externalPrompt is a pane's outstanding external-launch confirmation.
391+type externalPrompt struct {
392+ token uint64
393+ identity webViewIdentity
394+}
395+
390 // promptExternalLaunch asks the user whether the external URI may be handed to396 // promptExternalLaunch asks the user whether the external URI may be handed to
391 // the system handler. A pane has at most one outstanding prompt: further397 // the system handler. A pane has at most one outstanding prompt: further
392 // external URIs from that pane are dropped until it is answered, so a page398 // external URIs from that pane are dropped until it is answered, so a page
393 // cannot flood the window's prompt queue.399 // cannot flood the window's prompt queue.
394 func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {400 func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
395 log := logging.FromContext(ctx)401 log := logging.FromContext(ctx)
396- if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {402+ identity, ok := identityForWebView(wv)
403+ if !ok || c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
397 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")404 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
398 return405 return
399 }406 }
@@ -404,22 +411,26 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
404 return411 return
405 }412 }
406 if c.externalPrompts == nil {413 if c.externalPrompts == nil {
407- c.externalPrompts = make(map[entity.PaneID]port.WebView)414+ c.externalPrompts = make(map[entity.PaneID]externalPrompt)
408 }415 }
409- c.externalPrompts[paneID] = wv416+ c.externalPromptSeq++
417+ token := c.externalPromptSeq
418+ c.externalPrompts[paneID] = externalPrompt{token: token, identity: identity}
419+ origin := c.externalPromptOrigins[paneID]
410 c.externalPromptMu.Unlock()420 c.externalPromptMu.Unlock()
411 421
412- c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {422+ c.onConfirmExternalLaunch(paneID, origin, uri, func(allowed bool) {
413 c.externalPromptMu.Lock()423 c.externalPromptMu.Lock()
414- owner, pending := c.externalPrompts[paneID]424+ prompt, pending := c.externalPrompts[paneID]
415- // The prompt is stale when its pane was released (or rebound to425+ // The prompt is stale when its pane was released, rebound, or
416- // another WebView) while it was queued: never launch for it.426+ // re-prompted while it was queued: never launch for it.
417- current := pending && owner == wv427+ current := pending && prompt.token == token
418 if current {428 if current {
419 delete(c.externalPrompts, paneID)429 delete(c.externalPrompts, paneID)
420 }430 }
421 c.externalPromptMu.Unlock()431 c.externalPromptMu.Unlock()
422- current = current && c.GetWebView(paneID) == wv432+ bound, ok := identityForWebView(c.GetWebView(paneID))
433+ current = current && ok && bound == identity
423 434
424 log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).435 log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
425 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).436 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
@@ -430,11 +441,27 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
430 })441 })
431 }442 }
432 443
444+// rememberExternalPromptOrigin records the host of the last web page shown in
445+// the pane, so a later external-launch prompt can name the requesting site.
446+func (c *Coordinator) rememberExternalPromptOrigin(paneID entity.PaneID, uri string) {
447+ host := logging.SafeURLHost(uri)
448+ if host == "" {
449+ return
450+ }
451+ c.externalPromptMu.Lock()
452+ if c.externalPromptOrigins == nil {
453+ c.externalPromptOrigins = make(map[entity.PaneID]string)
454+ }
455+ c.externalPromptOrigins[paneID] = host
456+ c.externalPromptMu.Unlock()
457+}
458+
433 // clearExternalPrompt invalidates the pane's outstanding external-launch459 // clearExternalPrompt invalidates the pane's outstanding external-launch
434-// prompt so a late answer cannot launch anything.460+// prompt so a late answer cannot launch anything, and forgets its origin.
435 func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {461 func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
436 c.externalPromptMu.Lock()462 c.externalPromptMu.Lock()
437 delete(c.externalPrompts, paneID)463 delete(c.externalPrompts, paneID)
464+ delete(c.externalPromptOrigins, paneID)
438 c.externalPromptMu.Unlock()465 c.externalPromptMu.Unlock()
439 }466 }
440 467
@@ -467,6 +494,7 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
467 return494 return
468 }495 }
469 496
497+ c.rememberExternalPromptOrigin(paneID, uri)
470 if !wv.IsLoading() {498 if !wv.IsLoading() {
471 c.onSPANavigation(ctx, paneID, uri)499 c.onSPANavigation(ctx, paneID, uri)
472 }500 }
M internal/ui/coordinator/content/navigation_security_test.go +79 -7
@@ -14,16 +14,26 @@ import (
1414
1515 type externalLaunchRecorder struct {
1616 prompts []string
17+ origins []string
1718 respond func(bool)
1819 launched []string
1920 }
2021
22+// externalWebView returns a WebView mock with ID 1 and the given generation.
23+func externalWebView(t *testing.T, generation uint64) *mocks.MockWebView {
24+ wv := mocks.NewMockWebView(t)
25+ wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
26+ wv.EXPECT().Generation().Return(generation).Maybe()
27+ return wv
28+}
29+
2130 func (r *externalLaunchRecorder) install(c *Coordinator) {
2231 c.onLaunchExternalURL = func(uri string) {
2332 r.launched = append(r.launched, uri)
2433 }
25- c.onConfirmExternalLaunch = func(_ entity.PaneID, uri string, respond func(bool)) {
34+ c.onConfirmExternalLaunch = func(_ entity.PaneID, origin, uri string, respond func(bool)) {
2635 r.prompts = append(r.prompts, uri)
36+ r.origins = append(r.origins, origin)
2737 r.respond = respond
2838 }
2939 }
@@ -32,7 +42,7 @@ func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T)
3242 t.Parallel()
3343
3444 ctx := context.Background()
35- wv := mocks.NewMockWebView(t)
45+ wv := externalWebView(t, 1)
3646 wv.EXPECT().Stop(ctx).Return(nil).Once()
3747
3848 launchCount := 0
@@ -49,7 +59,7 @@ func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing
4959 t.Parallel()
5060
5161 ctx := context.Background()
52- wv := mocks.NewMockWebView(t)
62+ wv := externalWebView(t, 1)
5363 wv.EXPECT().Stop(ctx).Return(nil).Once()
5464
5565 rec := &externalLaunchRecorder{}
@@ -70,7 +80,7 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
7080 t.Parallel()
7181
7282 ctx := context.Background()
73- wv := mocks.NewMockWebView(t)
83+ wv := externalWebView(t, 1)
7484 // GoBack must not be called: going back to an OAuth endpoint loops.
7585 wv.EXPECT().Stop(ctx).Return(nil).Once()
7686
@@ -88,7 +98,7 @@ func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
8898 t.Parallel()
8999
90100 ctx := context.Background()
91- wv := mocks.NewMockWebView(t)
101+ wv := externalWebView(t, 1)
92102 wv.EXPECT().Stop(ctx).Return(nil).Times(3)
93103
94104 rec := &externalLaunchRecorder{}
@@ -112,7 +122,7 @@ func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
112122 t.Parallel()
113123
114124 ctx := context.Background()
115- wv := mocks.NewMockWebView(t)
125+ wv := externalWebView(t, 1)
116126 wv.EXPECT().Stop(ctx).Return(nil).Once()
117127
118128 rec := &externalLaunchRecorder{}
@@ -155,7 +165,7 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
155165 t.Parallel()
156166
157167 ctx := context.Background()
158- wv := mocks.NewMockWebView(t)
168+ wv := externalWebView(t, 1)
159169 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
160170
161171 rec := &externalLaunchRecorder{}
@@ -167,3 +177,65 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
167177 assert.Len(t, rec.prompts, 1)
168178 assert.Empty(t, rec.launched)
169179 }
180+
181+func TestHandleURIChanged_OldAnswerCannotLaunchReplacementPrompt(t *testing.T) {
182+ t.Parallel()
183+
184+ ctx := context.Background()
185+ wv := externalWebView(t, 1)
186+ wv.EXPECT().Stop(ctx).Return(nil).Twice()
187+
188+ rec := &externalLaunchRecorder{}
189+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
190+ rec.install(c)
191+
192+ c.handleURIChanged(ctx, "pane-1", wv, "claude://old")
193+ oldRespond := rec.respond
194+ // The pane is invalidated, then the same WebView asks again.
195+ c.clearExternalPrompt("pane-1")
196+ c.handleURIChanged(ctx, "pane-1", wv, "claude://new")
197+ newRespond := rec.respond
198+
199+ oldRespond(true)
200+ assert.Empty(t, rec.launched, "old answer must not launch")
201+
202+ newRespond(true)
203+ assert.Equal(t, []string{"claude://new"}, rec.launched)
204+}
205+
206+func TestHandleURIChanged_RebindToNewGenerationMakesPromptStale(t *testing.T) {
207+ t.Parallel()
208+
209+ ctx := context.Background()
210+ wv := externalWebView(t, 1)
211+ wv.EXPECT().Stop(ctx).Return(nil).Once()
212+ rebound := externalWebView(t, 2)
213+
214+ rec := &externalLaunchRecorder{}
215+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
216+ rec.install(c)
217+
218+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login?code=x")
219+ c.webViews["pane-1"] = rebound
220+
221+ rec.respond(true)
222+ assert.Empty(t, rec.launched)
223+}
224+
225+func TestHandleURIChanged_PromptNamesLastWebOrigin(t *testing.T) {
226+ t.Parallel()
227+
228+ ctx := context.Background()
229+ wv := externalWebView(t, 1)
230+ wv.EXPECT().IsLoading().Return(true).Once()
231+ wv.EXPECT().Stop(ctx).Return(nil).Once()
232+
233+ rec := &externalLaunchRecorder{}
234+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
235+ rec.install(c)
236+
237+ c.handleURIChanged(ctx, "pane-1", wv, "https://claude.ai/login/popup-google-auth?code=x")
238+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login")
239+
240+ assert.Equal(t, []string{"claude.ai"}, rec.origins)
241+}
@@ -14,16 +14,26 @@ import (
14 14
15 type externalLaunchRecorder struct {15 type externalLaunchRecorder struct {
16 prompts []string16 prompts []string
17+ origins []string
17 respond func(bool)18 respond func(bool)
18 launched []string19 launched []string
19 }20 }
20 21
22+// externalWebView returns a WebView mock with ID 1 and the given generation.
23+func externalWebView(t *testing.T, generation uint64) *mocks.MockWebView {
24+ wv := mocks.NewMockWebView(t)
25+ wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
26+ wv.EXPECT().Generation().Return(generation).Maybe()
27+ return wv
28+}
29+
21 func (r *externalLaunchRecorder) install(c *Coordinator) {30 func (r *externalLaunchRecorder) install(c *Coordinator) {
22 c.onLaunchExternalURL = func(uri string) {31 c.onLaunchExternalURL = func(uri string) {
23 r.launched = append(r.launched, uri)32 r.launched = append(r.launched, uri)
24 }33 }
25- c.onConfirmExternalLaunch = func(_ entity.PaneID, uri string, respond func(bool)) {34+ c.onConfirmExternalLaunch = func(_ entity.PaneID, origin, uri string, respond func(bool)) {
26 r.prompts = append(r.prompts, uri)35 r.prompts = append(r.prompts, uri)
36+ r.origins = append(r.origins, origin)
27 r.respond = respond37 r.respond = respond
28 }38 }
29 }39 }
@@ -32,7 +42,7 @@ func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T)
32 t.Parallel()42 t.Parallel()
33 43
34 ctx := context.Background()44 ctx := context.Background()
35- wv := mocks.NewMockWebView(t)45+ wv := externalWebView(t, 1)
36 wv.EXPECT().Stop(ctx).Return(nil).Once()46 wv.EXPECT().Stop(ctx).Return(nil).Once()
37 47
38 launchCount := 048 launchCount := 0
@@ -49,7 +59,7 @@ func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing
49 t.Parallel()59 t.Parallel()
50 60
51 ctx := context.Background()61 ctx := context.Background()
52- wv := mocks.NewMockWebView(t)62+ wv := externalWebView(t, 1)
53 wv.EXPECT().Stop(ctx).Return(nil).Once()63 wv.EXPECT().Stop(ctx).Return(nil).Once()
54 64
55 rec := &externalLaunchRecorder{}65 rec := &externalLaunchRecorder{}
@@ -70,7 +80,7 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
70 t.Parallel()80 t.Parallel()
71 81
72 ctx := context.Background()82 ctx := context.Background()
73- wv := mocks.NewMockWebView(t)83+ wv := externalWebView(t, 1)
74 // GoBack must not be called: going back to an OAuth endpoint loops.84 // GoBack must not be called: going back to an OAuth endpoint loops.
75 wv.EXPECT().Stop(ctx).Return(nil).Once()85 wv.EXPECT().Stop(ctx).Return(nil).Once()
76 86
@@ -88,7 +98,7 @@ func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
88 t.Parallel()98 t.Parallel()
89 99
90 ctx := context.Background()100 ctx := context.Background()
91- wv := mocks.NewMockWebView(t)101+ wv := externalWebView(t, 1)
92 wv.EXPECT().Stop(ctx).Return(nil).Times(3)102 wv.EXPECT().Stop(ctx).Return(nil).Times(3)
93 103
94 rec := &externalLaunchRecorder{}104 rec := &externalLaunchRecorder{}
@@ -112,7 +122,7 @@ func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
112 t.Parallel()122 t.Parallel()
113 123
114 ctx := context.Background()124 ctx := context.Background()
115- wv := mocks.NewMockWebView(t)125+ wv := externalWebView(t, 1)
116 wv.EXPECT().Stop(ctx).Return(nil).Once()126 wv.EXPECT().Stop(ctx).Return(nil).Once()
117 127
118 rec := &externalLaunchRecorder{}128 rec := &externalLaunchRecorder{}
@@ -155,7 +165,7 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
155 t.Parallel()165 t.Parallel()
156 166
157 ctx := context.Background()167 ctx := context.Background()
158- wv := mocks.NewMockWebView(t)168+ wv := externalWebView(t, 1)
159 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()169 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
160 170
161 rec := &externalLaunchRecorder{}171 rec := &externalLaunchRecorder{}
@@ -167,3 +177,65 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
167 assert.Len(t, rec.prompts, 1)177 assert.Len(t, rec.prompts, 1)
168 assert.Empty(t, rec.launched)178 assert.Empty(t, rec.launched)
169 }179 }
180+
181+func TestHandleURIChanged_OldAnswerCannotLaunchReplacementPrompt(t *testing.T) {
182+ t.Parallel()
183+
184+ ctx := context.Background()
185+ wv := externalWebView(t, 1)
186+ wv.EXPECT().Stop(ctx).Return(nil).Twice()
187+
188+ rec := &externalLaunchRecorder{}
189+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
190+ rec.install(c)
191+
192+ c.handleURIChanged(ctx, "pane-1", wv, "claude://old")
193+ oldRespond := rec.respond
194+ // The pane is invalidated, then the same WebView asks again.
195+ c.clearExternalPrompt("pane-1")
196+ c.handleURIChanged(ctx, "pane-1", wv, "claude://new")
197+ newRespond := rec.respond
198+
199+ oldRespond(true)
200+ assert.Empty(t, rec.launched, "old answer must not launch")
201+
202+ newRespond(true)
203+ assert.Equal(t, []string{"claude://new"}, rec.launched)
204+}
205+
206+func TestHandleURIChanged_RebindToNewGenerationMakesPromptStale(t *testing.T) {
207+ t.Parallel()
208+
209+ ctx := context.Background()
210+ wv := externalWebView(t, 1)
211+ wv.EXPECT().Stop(ctx).Return(nil).Once()
212+ rebound := externalWebView(t, 2)
213+
214+ rec := &externalLaunchRecorder{}
215+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
216+ rec.install(c)
217+
218+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login?code=x")
219+ c.webViews["pane-1"] = rebound
220+
221+ rec.respond(true)
222+ assert.Empty(t, rec.launched)
223+}
224+
225+func TestHandleURIChanged_PromptNamesLastWebOrigin(t *testing.T) {
226+ t.Parallel()
227+
228+ ctx := context.Background()
229+ wv := externalWebView(t, 1)
230+ wv.EXPECT().IsLoading().Return(true).Once()
231+ wv.EXPECT().Stop(ctx).Return(nil).Once()
232+
233+ rec := &externalLaunchRecorder{}
234+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
235+ rec.install(c)
236+
237+ c.handleURIChanged(ctx, "pane-1", wv, "https://claude.ai/login/popup-google-auth?code=x")
238+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login")
239+
240+ assert.Equal(t, []string{"claude.ai"}, rec.origins)
241+}
M internal/ui/dialog/permission.go +13 -8
@@ -73,13 +73,14 @@ func (d *PermissionDialog) ShowPermissionDialog(
7373 d.showRequest(req)
7474 }
7575
76-// ShowExternalLaunchConfirm asks whether an external application may open uri.
77-// The prompt shares the permission popup queue and is never persistable.
78-func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool)) {
76+// ShowExternalLaunchConfirm asks whether an external application may open uri
77+// on behalf of origin (the requesting site's host, may be empty). The prompt
78+// shares the permission popup queue and is never persistable.
79+func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, origin, uri string, callback func(allowed bool)) {
7980 req := permissionDialogRequest{
8081 ctx: ctx,
8182 heading: "Open External Application?",
82- body: externalLaunchBody(uri),
83+ body: externalLaunchBody(origin, uri),
8384 callback: func(result port.PermissionDialogResult) {
8485 callback(result.Allowed)
8586 },
@@ -90,14 +91,18 @@ func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri st
9091 d.showRequest(req)
9192 }
9293
93-// externalLaunchBody names only the scheme and host so OAuth codes in the
94-// query string are never displayed.
95-func externalLaunchBody(uri string) string {
94+// externalLaunchBody names the requesting host and only the scheme and host of
95+// the target, so OAuth codes in the query string are never displayed.
96+func externalLaunchBody(origin, uri string) string {
9697 target := uri
9798 if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" {
9899 target = parsed.Scheme + "://" + parsed.Host
99100 }
100- return fmt.Sprintf("This page wants to open %s in an external application.", target)
101+ requester := "A page"
102+ if origin != "" {
103+ requester = origin
104+ }
105+ return fmt.Sprintf("%s wants to open %s in an external application.", requester, target)
101106 }
102107
103108 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
@@ -73,13 +73,14 @@ func (d *PermissionDialog) ShowPermissionDialog(
73 d.showRequest(req)73 d.showRequest(req)
74 }74 }
75 75
76-// ShowExternalLaunchConfirm asks whether an external application may open uri.76+// ShowExternalLaunchConfirm asks whether an external application may open uri
77-// The prompt shares the permission popup queue and is never persistable.77+// on behalf of origin (the requesting site's host, may be empty). The prompt
78-func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool)) {78+// shares the permission popup queue and is never persistable.
79+func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, origin, uri string, callback func(allowed bool)) {
79 req := permissionDialogRequest{80 req := permissionDialogRequest{
80 ctx: ctx,81 ctx: ctx,
81 heading: "Open External Application?",82 heading: "Open External Application?",
82- body: externalLaunchBody(uri),83+ body: externalLaunchBody(origin, uri),
83 callback: func(result port.PermissionDialogResult) {84 callback: func(result port.PermissionDialogResult) {
84 callback(result.Allowed)85 callback(result.Allowed)
85 },86 },
@@ -90,14 +91,18 @@ func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri st
90 d.showRequest(req)91 d.showRequest(req)
91 }92 }
92 93
93-// externalLaunchBody names only the scheme and host so OAuth codes in the94+// externalLaunchBody names the requesting host and only the scheme and host of
94-// query string are never displayed.95+// the target, so OAuth codes in the query string are never displayed.
95-func externalLaunchBody(uri string) string {96+func externalLaunchBody(origin, uri string) string {
96 target := uri97 target := uri
97 if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" {98 if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" {
98 target = parsed.Scheme + "://" + parsed.Host99 target = parsed.Scheme + "://" + parsed.Host
99 }100 }
100- return fmt.Sprintf("This page wants to open %s in an external application.", target)101+ requester := "A page"
102+ if origin != "" {
103+ requester = origin
104+ }
105+ return fmt.Sprintf("%s wants to open %s in an external application.", requester, target)
101 }106 }
102 107
103 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {108 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
M internal/ui/dialog/permission_test.go +2 -2
@@ -269,9 +269,9 @@ func TestPermissionDialog_ExternalLaunchConfirmHidesQueryAndIsNotPersistable(t *
269269
270270 d := &PermissionDialog{popup: popup}
271271 var allowed bool
272- d.ShowExternalLaunchConfirm(context.Background(), "claude://login/google-auth?code=secret", func(a bool) { allowed = a })
272+ d.ShowExternalLaunchConfirm(context.Background(), "claude.ai", "claude://login/google-auth?code=secret", func(a bool) { allowed = a })
273273
274- assert.Contains(t, body, "claude://login")
274+ assert.Contains(t, body, "claude.ai wants to open claude://login")
275275 assert.NotContains(t, body, "secret")
276276 assert.False(t, persistable)
277277
@@ -269,9 +269,9 @@ func TestPermissionDialog_ExternalLaunchConfirmHidesQueryAndIsNotPersistable(t *
269 269
270 d := &PermissionDialog{popup: popup}270 d := &PermissionDialog{popup: popup}
271 var allowed bool271 var allowed bool
272- d.ShowExternalLaunchConfirm(context.Background(), "claude://login/google-auth?code=secret", func(a bool) { allowed = a })272+ d.ShowExternalLaunchConfirm(context.Background(), "claude.ai", "claude://login/google-auth?code=secret", func(a bool) { allowed = a })
273 273
274- assert.Contains(t, body, "claude://login")274+ assert.Contains(t, body, "claude.ai wants to open claude://login")
275 assert.NotContains(t, body, "secret")275 assert.NotContains(t, body, "secret")
276 assert.False(t, persistable)276 assert.False(t, persistable)
277 277

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close