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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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() { | |||
| 3605 | 3605 | ||
| 3606 | 3606 | // externalLaunchConfirmer is implemented by the window permission dialog. | |
| 3607 | 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 | 3611 | // confirmExternalLaunch asks, in the window owning paneID, whether uri may be | |
| 3612 | 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 | 3614 | ctx := context.Background() | |
| 3615 | 3615 | if a.deps != nil && a.deps.Ctx != nil { | |
| 3616 | 3616 | ctx = a.deps.Ctx | |
| @@ -3620,7 +3620,7 @@ func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond fu | |||
| 3620 | 3620 | respond(false) | |
| 3621 | 3621 | return | |
| 3622 | 3622 | } | |
| 3623 | - | confirmer.ShowExternalLaunchConfirm(ctx, uri, respond) | |
| 3623 | + | confirmer.ShowExternalLaunchConfirm(ctx, origin, uri, respond) | |
| 3624 | 3624 | } | |
| 3625 | 3625 | ||
| 3626 | 3626 | // 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 be | 3611 | // 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.Ctx | 3616 | 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 | return | 3621 | 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 the | 3626 | // 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 { | |||
| 136 | 136 | onLaunchExternalURL func(uri string) | |
| 137 | 137 | // Callback asking the user to confirm an external scheme launch for a pane. | |
| 138 | 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 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 | |
| 145 | 148 | } | |
| 146 | 149 | ||
| 147 | 150 | type pendingThemeUpdate struct { | |
| @@ -317,7 +320,7 @@ func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) { | |||
| 317 | 320 | ||
| 318 | 321 | // SetOnConfirmExternalLaunch sets the callback that asks the user whether an | |
| 319 | 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 | 324 | c.onConfirmExternalLaunch = fn | |
| 322 | 325 | } | |
| 323 | 326 | ||
| @@ -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 outstanding | 142 | + | // 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.Mutex | 144 | + | externalPromptMu sync.Mutex |
| 144 | - | externalPrompts map[entity.PaneID]port.WebView | 145 | + | 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 an | 321 | // 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 = fn | 324 | 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) { | |||
| 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 | 396 | // promptExternalLaunch asks the user whether the external URI may be handed to | |
| 391 | 397 | // the system handler. A pane has at most one outstanding prompt: further | |
| 392 | 398 | // external URIs from that pane are dropped until it is answered, so a page | |
| 393 | 399 | // cannot flood the window's prompt queue. | |
| 394 | 400 | func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) { | |
| 395 | 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 | 404 | log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked") | |
| 398 | 405 | return | |
| 399 | 406 | } | |
| @@ -404,22 +411,26 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa | |||
| 404 | 411 | return | |
| 405 | 412 | } | |
| 406 | 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] = wv | |
| 416 | + | c.externalPromptSeq++ | |
| 417 | + | token := c.externalPromptSeq | |
| 418 | + | c.externalPrompts[paneID] = externalPrompt{token: token, identity: identity} | |
| 419 | + | origin := c.externalPromptOrigins[paneID] | |
| 410 | 420 | c.externalPromptMu.Unlock() | |
| 411 | 421 | ||
| 412 | - | c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) { | |
| 422 | + | c.onConfirmExternalLaunch(paneID, origin, uri, func(allowed bool) { | |
| 413 | 423 | 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 | |
| 418 | 428 | if current { | |
| 419 | 429 | delete(c.externalPrompts, paneID) | |
| 420 | 430 | } | |
| 421 | 431 | c.externalPromptMu.Unlock() | |
| 422 | - | current = current && c.GetWebView(paneID) == wv | |
| 432 | + | bound, ok := identityForWebView(c.GetWebView(paneID)) | |
| 433 | + | current = current && ok && bound == identity | |
| 423 | 434 | ||
| 424 | 435 | log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current). | |
| 425 | 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 | 459 | // 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 | 461 | func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) { | |
| 436 | 462 | c.externalPromptMu.Lock() | |
| 437 | 463 | delete(c.externalPrompts, paneID) | |
| 464 | + | delete(c.externalPromptOrigins, paneID) | |
| 438 | 465 | c.externalPromptMu.Unlock() | |
| 439 | 466 | } | |
| 440 | 467 | ||
| @@ -467,6 +494,7 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID | |||
| 467 | 494 | return | |
| 468 | 495 | } | |
| 469 | 496 | ||
| 497 | + | c.rememberExternalPromptOrigin(paneID, uri) | |
| 470 | 498 | if !wv.IsLoading() { | |
| 471 | 499 | c.onSPANavigation(ctx, paneID, uri) | |
| 472 | 500 | } | |
| @@ -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 to | 396 | // promptExternalLaunch asks the user whether the external URI may be handed to | ||
| 391 | // the system handler. A pane has at most one outstanding prompt: further | 397 | // 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 page | 398 | // 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 | return | 405 | return | ||
| 399 | } | 406 | } | ||
| @@ -404,22 +411,26 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa | |||||
| 404 | return | 411 | 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] = wv | 416 | + | 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 to | 425 | + | // 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 == wv | 427 | + | 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) == wv | 432 | + | 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-launch | 459 | // 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 | return | 494 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | type externalLaunchRecorder struct { | |
| 16 | 16 | prompts []string | |
| 17 | + | origins []string | |
| 17 | 18 | respond func(bool) | |
| 18 | 19 | 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 | 30 | func (r *externalLaunchRecorder) install(c *Coordinator) { | |
| 22 | 31 | c.onLaunchExternalURL = func(uri string) { | |
| 23 | 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 | 35 | r.prompts = append(r.prompts, uri) | |
| 36 | + | r.origins = append(r.origins, origin) | |
| 27 | 37 | r.respond = respond | |
| 28 | 38 | } | |
| 29 | 39 | } | |
| @@ -32,7 +42,7 @@ func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T) | |||
| 32 | 42 | t.Parallel() | |
| 33 | 43 | ||
| 34 | 44 | ctx := context.Background() | |
| 35 | - | wv := mocks.NewMockWebView(t) | |
| 45 | + | wv := externalWebView(t, 1) | |
| 36 | 46 | wv.EXPECT().Stop(ctx).Return(nil).Once() | |
| 37 | 47 | ||
| 38 | 48 | launchCount := 0 | |
| @@ -49,7 +59,7 @@ func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing | |||
| 49 | 59 | t.Parallel() | |
| 50 | 60 | ||
| 51 | 61 | ctx := context.Background() | |
| 52 | - | wv := mocks.NewMockWebView(t) | |
| 62 | + | wv := externalWebView(t, 1) | |
| 53 | 63 | wv.EXPECT().Stop(ctx).Return(nil).Once() | |
| 54 | 64 | ||
| 55 | 65 | rec := &externalLaunchRecorder{} | |
| @@ -70,7 +80,7 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T | |||
| 70 | 80 | t.Parallel() | |
| 71 | 81 | ||
| 72 | 82 | ctx := context.Background() | |
| 73 | - | wv := mocks.NewMockWebView(t) | |
| 83 | + | wv := externalWebView(t, 1) | |
| 74 | 84 | // GoBack must not be called: going back to an OAuth endpoint loops. | |
| 75 | 85 | wv.EXPECT().Stop(ctx).Return(nil).Once() | |
| 76 | 86 | ||
| @@ -88,7 +98,7 @@ func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) { | |||
| 88 | 98 | t.Parallel() | |
| 89 | 99 | ||
| 90 | 100 | ctx := context.Background() | |
| 91 | - | wv := mocks.NewMockWebView(t) | |
| 101 | + | wv := externalWebView(t, 1) | |
| 92 | 102 | wv.EXPECT().Stop(ctx).Return(nil).Times(3) | |
| 93 | 103 | ||
| 94 | 104 | rec := &externalLaunchRecorder{} | |
| @@ -112,7 +122,7 @@ func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) { | |||
| 112 | 122 | t.Parallel() | |
| 113 | 123 | ||
| 114 | 124 | ctx := context.Background() | |
| 115 | - | wv := mocks.NewMockWebView(t) | |
| 125 | + | wv := externalWebView(t, 1) | |
| 116 | 126 | wv.EXPECT().Stop(ctx).Return(nil).Once() | |
| 117 | 127 | ||
| 118 | 128 | rec := &externalLaunchRecorder{} | |
| @@ -155,7 +165,7 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) { | |||
| 155 | 165 | t.Parallel() | |
| 156 | 166 | ||
| 157 | 167 | ctx := context.Background() | |
| 158 | - | wv := mocks.NewMockWebView(t) | |
| 168 | + | wv := externalWebView(t, 1) | |
| 159 | 169 | wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once() | |
| 160 | 170 | ||
| 161 | 171 | rec := &externalLaunchRecorder{} | |
| @@ -167,3 +177,65 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) { | |||
| 167 | 177 | assert.Len(t, rec.prompts, 1) | |
| 168 | 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 | + | } | |
| @@ -14,16 +14,26 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | type externalLaunchRecorder struct { | 15 | type externalLaunchRecorder struct { | ||
| 16 | prompts []string | 16 | prompts []string | ||
| 17 | + | origins []string | |||
| 17 | respond func(bool) | 18 | respond func(bool) | ||
| 18 | launched []string | 19 | 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 = respond | 37 | 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 := 0 | 48 | 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( | |||
| 73 | 73 | d.showRequest(req) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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)) { | |
| 79 | 80 | req := permissionDialogRequest{ | |
| 80 | 81 | ctx: ctx, | |
| 81 | 82 | heading: "Open External Application?", | |
| 82 | - | body: externalLaunchBody(uri), | |
| 83 | + | body: externalLaunchBody(origin, uri), | |
| 83 | 84 | callback: func(result port.PermissionDialogResult) { | |
| 84 | 85 | callback(result.Allowed) | |
| 85 | 86 | }, | |
| @@ -90,14 +91,18 @@ func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri st | |||
| 90 | 91 | d.showRequest(req) | |
| 91 | 92 | } | |
| 92 | 93 | ||
| 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 { | |
| 96 | 97 | target := uri | |
| 97 | 98 | if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" { | |
| 98 | 99 | 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 | 108 | 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 the | 94 | + | // 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 := uri | 97 | 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.Host | 99 | 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 * | |||
| 269 | 269 | ||
| 270 | 270 | d := &PermissionDialog{popup: popup} | |
| 271 | 271 | 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 | 275 | assert.NotContains(t, body, "secret") | |
| 276 | 276 | assert.False(t, persistable) | |
| 277 | 277 | ||
| @@ -269,9 +269,9 @@ func TestPermissionDialog_ExternalLaunchConfirmHidesQueryAndIsNotPersistable(t * | |||||
| 269 | 269 | ||||
| 270 | d := &PermissionDialog{popup: popup} | 270 | d := &PermissionDialog{popup: popup} | ||
| 271 | var allowed bool | 271 | 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 | ||||