Merge pull request #363 from bnema/fix/floating-pane-browsing-contexts
fix(browsing): route floating pane browsing contexts safely
49 files · +4478 -1417
M .mockery.yaml +1 -0
| @@ -85,6 +85,7 @@ packages: | |||
| 85 | 85 | FaviconDatabase: {} | |
| 86 | 86 | IdleInhibitor: {} | |
| 87 | 87 | PopupLifecycleCapable: {} | |
| 88 | + | PopupFeatureResolver: {} | |
| 88 | 89 | PopupOpenerCapable: {} | |
| 89 | 90 | OAuthCallbackCapable: {} | |
| 90 | 91 | DevToolsOpener: {} | |
M cmd/dumber/main.go +9 -0
| @@ -147,6 +147,15 @@ type startupTiming struct { | |||
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | 149 | func main() { | |
| 150 | + | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |
| 151 | + | // invalidate frames retained across those boundaries and make the GC abort | |
| 152 | + | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| 153 | + | // sees the safety setting before it creates any goroutine or CEF subprocess. | |
| 154 | + | if err := ensureRuntimeSafety(); err != nil { | |
| 155 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 156 | + | os.Exit(1) | |
| 157 | + | } | |
| 158 | + | ||
| 150 | 159 | // CEF is not known until configuration is complete. Keep these neutral | |
| 151 | 160 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | |
| 152 | 161 | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | |
A cmd/dumber/runtime_safety.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | "syscall" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func environmentValue(environ []string, name string) string { | |
| 11 | + | prefix := name + "=" | |
| 12 | + | for _, entry := range environ { | |
| 13 | + | if strings.HasPrefix(entry, prefix) { | |
| 14 | + | return strings.TrimPrefix(entry, prefix) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | return "" | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func replaceEnvironmentValue(environ []string, name, value string) []string { | |
| 21 | + | prefix := name + "=" | |
| 22 | + | updated := make([]string, 0, len(environ)+1) | |
| 23 | + | for _, entry := range environ { | |
| 24 | + | if !strings.HasPrefix(entry, prefix) { | |
| 25 | + | updated = append(updated, entry) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return append(updated, prefix+value) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func runtimeSafetyEnvironment(environ []string) ([]string, bool) { | |
| 32 | + | updated := append([]string(nil), environ...) | |
| 33 | + | const setting = "gcshrinkstackoff" | |
| 34 | + | parts := strings.Split(environmentValue(updated, "GODEBUG"), ",") | |
| 35 | + | kept := make([]string, 0, len(parts)+1) | |
| 36 | + | alreadySafe := false | |
| 37 | + | for _, part := range parts { | |
| 38 | + | part = strings.TrimSpace(part) | |
| 39 | + | if part == "" { | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | key, value, found := strings.Cut(part, "=") | |
| 43 | + | if key == setting { | |
| 44 | + | alreadySafe = found && value == "1" | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | kept = append(kept, part) | |
| 48 | + | } | |
| 49 | + | if alreadySafe { | |
| 50 | + | return updated, false | |
| 51 | + | } | |
| 52 | + | kept = append(kept, setting+"=1") | |
| 53 | + | updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ",")) | |
| 54 | + | return updated, true | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func ensureRuntimeSafety() error { | |
| 58 | + | environ, reexec := runtimeSafetyEnvironment(os.Environ()) | |
| 59 | + | if !reexec { | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | executable, err := os.Executable() | |
| 63 | + | if err != nil { | |
| 64 | + | return fmt.Errorf("resolve executable for runtime safety re-exec: %w", err) | |
| 65 | + | } | |
| 66 | + | //nolint:gosec // G702: os.Executable and the current process argv are required for self-re-exec. | |
| 67 | + | if err := syscall.Exec(executable, os.Args, environ); err != nil { | |
| 68 | + | return fmt.Errorf("runtime safety re-exec: %w", err) | |
| 69 | + | } | |
| 70 | + | return nil | |
| 71 | + | } | |
A cmd/dumber/runtime_safety_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRuntimeSafetyEnvironmentDisablesStackShrinkingBeforeStartup(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | environ []string | |
| 9 | + | wantGODEBUG string | |
| 10 | + | wantReexec bool | |
| 11 | + | }{ | |
| 12 | + | {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |
| 13 | + | {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |
| 14 | + | {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |
| 15 | + | {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: false}, | |
| 16 | + | {name: "forged marker is ignored", environ: []string{"DUMBER_RUNTIME_SAFETY_REEXEC=1", "GODEBUG=gcshrinkstackoff=0"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |
| 17 | + | } | |
| 18 | + | for _, tt := range tests { | |
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |
| 20 | + | got, reexec := runtimeSafetyEnvironment(tt.environ) | |
| 21 | + | if reexec != tt.wantReexec { | |
| 22 | + | t.Fatalf("reexec = %v, want %v", reexec, tt.wantReexec) | |
| 23 | + | } | |
| 24 | + | if value := environmentValue(got, "GODEBUG"); value != tt.wantGODEBUG { | |
| 25 | + | t.Fatalf("GODEBUG = %q, want %q", value, tt.wantGODEBUG) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
M docs/reference/floating-pane.md +6 -0
| @@ -16,6 +16,12 @@ The floating pane is an overlay workspace pane for quick-access pages like searc | |||
| 16 | 16 | - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView. | |
| 17 | 17 | - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state. | |
| 18 | 18 | ||
| 19 | + | ### Links and popups | |
| 20 | + | ||
| 21 | + | New-page links, middle-clicks, and featureless `_blank` `window.open()` calls from a floating pane navigate that floating pane's current page. They do not open a separate Dumber window. | |
| 22 | + | ||
| 23 | + | OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. Named browsing contexts continue to reuse an existing live context when one is available. | |
| 24 | + | ||
| 19 | 25 | ## Configuration | |
| 20 | 26 | ||
| 21 | 27 | | Key | Type | Default | Description | | |
M go.mod +2 -2
| @@ -6,8 +6,8 @@ require ( | |||
| 6 | 6 | github.com/a-h/templ v0.3.1020 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.2 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | |
| 9 | - | github.com/bnema/purego-cef v0.14.1 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.8.5 | |
| 9 | + | github.com/bnema/purego-cef v0.14.2 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.9.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.5 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.4 | |
| 13 | 13 | github.com/bnema/purego-webp v0.2.1 | |
M go.sum +4 -4
| @@ -12,10 +12,10 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ | |||
| 12 | 12 | github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= | |
| 13 | 13 | github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3kRprbs= | |
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg= | |
| 15 | - | github.com/bnema/purego-cef v0.14.1 h1:ZAlrGfFyk3Eel0Gct+ipzJnnNfpXfDX8NuoZfRKAKco= | |
| 16 | - | github.com/bnema/purego-cef v0.14.1/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg= | |
| 17 | - | github.com/bnema/purego-cef2gtk v0.8.5 h1:Qwt9DkvCVBNN52dp1jikFU5B2Sg3dB16ayi/T4+2Bhg= | |
| 18 | - | github.com/bnema/purego-cef2gtk v0.8.5/go.mod h1:w/5Bjkm+ZaZbDIDS+ysC5qTBcpb8/WOeGDva8i39ocM= | |
| 15 | + | github.com/bnema/purego-cef v0.14.2 h1:0tKXT00gFvWUsL3A4sX4kzD+3XRmPOif0ccwO9iokqU= | |
| 16 | + | github.com/bnema/purego-cef v0.14.2/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg= | |
| 17 | + | github.com/bnema/purego-cef2gtk v0.9.0 h1:i1jdYIHFO6y6eAhzQ4Djy1t9z1GyH5ZrgR18f6C92Gc= | |
| 18 | + | github.com/bnema/purego-cef2gtk v0.9.0/go.mod h1:EqgpNih2djBXD9j7R5gs+gZcdQxzSgkYF3rf7eC+5zo= | |
| 19 | 19 | github.com/bnema/purego-pipewire v0.1.5 h1:wQQF2uHt/kVV3qOkdSE1CV0noQYkHS2+v9MXkgdjxEw= | |
| 20 | 20 | github.com/bnema/purego-pipewire v0.1.5/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk= | |
| 21 | 21 | github.com/bnema/purego-sqlite v0.1.4 h1:h2DCsYT1/Ps13Ua4knUNQmT5Xgj6eRx+DrywNcnFNEU= | |
M internal/application/dto/browsing_context.go +93 -24
| @@ -1,19 +1,56 @@ | |||
| 1 | 1 | package dto | |
| 2 | 2 | ||
| 3 | - | // WindowDisposition describes how the browsing context should be presented | |
| 4 | - | // relative to the source frame. Values mirror Chromium/CEF/WebKit navigation | |
| 5 | - | // dispositions at a normalized product seam. | |
| 3 | + | // SourceHostKind identifies the product host that originated a browsing-context request. | |
| 4 | + | type SourceHostKind string | |
| 5 | + | ||
| 6 | + | const ( | |
| 7 | + | // SourceHostWorkspace is the default persisted workspace pane host. | |
| 8 | + | SourceHostWorkspace SourceHostKind = "workspace" | |
| 9 | + | // SourceHostFloating is a transient floating pane outside the workspace tree. | |
| 10 | + | SourceHostFloating SourceHostKind = "floating" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // BrowserEngineKind identifies the browser engine that produced a normalized request. | |
| 14 | + | type BrowserEngineKind string | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | BrowserEngineUnknown BrowserEngineKind = "unknown" | |
| 18 | + | BrowserEngineCEF BrowserEngineKind = "cef" | |
| 19 | + | BrowserEngineWebKit BrowserEngineKind = "webkit" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // PopupFeatureState describes whether popup metadata is available and explicit. | |
| 23 | + | // Host policy treats its zero value as featureless, not PopupFeaturesUnknown; | |
| 24 | + | // popup producers must set State explicitly when resolution must be deferred. | |
| 25 | + | type PopupFeatureState string | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | PopupFeaturesUnknown PopupFeatureState = "unknown" | |
| 29 | + | PopupFeaturesNone PopupFeatureState = "none" | |
| 30 | + | PopupFeaturesSpecified PopupFeatureState = "specified" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // PopupFeatures is the engine-independent subset of window.open features used by host policy. | |
| 34 | + | type PopupFeatures struct { | |
| 35 | + | State PopupFeatureState | |
| 36 | + | ||
| 37 | + | X, Y, Width, Height int | |
| 38 | + | XSet, YSet, WidthSet, HeightSet bool | |
| 39 | + | ToolbarVisible, LocationbarVisible bool | |
| 40 | + | ToolbarVisibilitySet bool | |
| 41 | + | LocationbarVisibilitySet bool | |
| 42 | + | Resizable, ResizableSet bool | |
| 43 | + | IsPopup, IsPopupSet bool | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // WindowDisposition describes how the browsing context should be presented. | |
| 6 | 47 | type WindowDisposition string | |
| 7 | 48 | ||
| 8 | 49 | const ( | |
| 9 | - | // WindowDispositionCurrentTab replaces the current document in the same browsing context. | |
| 10 | 50 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | |
| 11 | - | // WindowDispositionNewTab opens in a new tab-like context. | |
| 12 | - | WindowDispositionNewTab WindowDisposition = "new-tab" | |
| 13 | - | // WindowDispositionNewPopup opens in a popup-style context. | |
| 14 | - | WindowDispositionNewPopup WindowDisposition = "new-popup" | |
| 15 | - | // WindowDispositionNewWindow opens in a new top-level window. | |
| 16 | - | WindowDispositionNewWindow WindowDisposition = "new-window" | |
| 51 | + | WindowDispositionNewTab WindowDisposition = "new-tab" | |
| 52 | + | WindowDispositionNewPopup WindowDisposition = "new-popup" | |
| 53 | + | WindowDispositionNewWindow WindowDisposition = "new-window" | |
| 17 | 54 | ) | |
| 18 | 55 | ||
| 19 | 56 | // TriggerKind categorizes what triggered a browsing-context request. | |
| @@ -27,22 +64,21 @@ const ( | |||
| 27 | 64 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | |
| 28 | 65 | ) | |
| 29 | 66 | ||
| 30 | - | // RequestContextDisposition makes request-context inheritance explicit at the | |
| 31 | - | // policy seam without leaking infrastructure request-context types upward. | |
| 67 | + | // RequestContextDisposition makes request-context inheritance explicit at the policy seam. | |
| 32 | 68 | type RequestContextDisposition string | |
| 33 | 69 | ||
| 34 | 70 | const ( | |
| 35 | - | // RequestContextInheritParent preserves the parent request context/session. | |
| 36 | 71 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | |
| 37 | - | // RequestContextIsolate starts from a fresh unrelated request context. | |
| 38 | - | RequestContextIsolate RequestContextDisposition = "isolate" | |
| 72 | + | RequestContextIsolate RequestContextDisposition = "isolate" | |
| 39 | 73 | ) | |
| 40 | 74 | ||
| 41 | - | // NewBrowsingContextRequest is the normalized request captured from engine/UI | |
| 42 | - | // signals before policy decides how it should be hosted. | |
| 75 | + | // NewBrowsingContextRequest is the normalized request captured before host policy runs. | |
| 43 | 76 | type NewBrowsingContextRequest struct { | |
| 44 | 77 | ParentWebViewID uint64 | |
| 45 | 78 | ||
| 79 | + | Engine BrowserEngineKind | |
| 80 | + | SourceHost SourceHostKind | |
| 81 | + | ||
| 46 | 82 | SourceBrowserID int32 | |
| 47 | 83 | SourceFrameID string | |
| 48 | 84 | SourceFrameURL string | |
| @@ -53,7 +89,7 @@ type NewBrowsingContextRequest struct { | |||
| 53 | 89 | ||
| 54 | 90 | IsUserGesture bool | |
| 55 | 91 | NoJavaScriptAccess bool | |
| 56 | - | WindowFeatures string | |
| 92 | + | PopupFeatures PopupFeatures | |
| 57 | 93 | ||
| 58 | 94 | TriggerKind TriggerKind | |
| 59 | 95 | AuthIntent bool | |
| @@ -61,24 +97,57 @@ type NewBrowsingContextRequest struct { | |||
| 61 | 97 | RequestContextDisposition RequestContextDisposition | |
| 62 | 98 | } | |
| 63 | 99 | ||
| 64 | - | // HostDecisionKind categorizes the approved host choices for a new browsing context. | |
| 100 | + | // HostDecisionKind categorizes approved hosts for a new browsing context. | |
| 65 | 101 | type HostDecisionKind string | |
| 66 | 102 | ||
| 67 | 103 | const ( | |
| 68 | - | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | |
| 69 | - | HostDecisionCreatePane HostDecisionKind = "create-pane" | |
| 70 | - | HostDecisionCreateNativeWin HostDecisionKind = "create-native-window" | |
| 71 | - | HostDecisionDeny HostDecisionKind = "deny" | |
| 104 | + | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | |
| 105 | + | HostDecisionNavigateSource HostDecisionKind = "navigate-source" | |
| 106 | + | HostDecisionCreatePane HostDecisionKind = "create-pane" | |
| 107 | + | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" | |
| 108 | + | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" | |
| 109 | + | HostDecisionAwaitPopupFeatures HostDecisionKind = "await-popup-features" | |
| 110 | + | HostDecisionDeny HostDecisionKind = "deny" | |
| 111 | + | ) | |
| 112 | + | ||
| 113 | + | // HostDecisionReasonCode is a stable machine-readable policy explanation. | |
| 114 | + | type HostDecisionReasonCode string | |
| 115 | + | ||
| 116 | + | const ( | |
| 117 | + | HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target" | |
| 118 | + | HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup" | |
| 119 | + | HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required" | |
| 120 | + | HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native" | |
| 121 | + | HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse" | |
| 122 | + | HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending" | |
| 123 | + | HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features" | |
| 124 | + | HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank" | |
| 125 | + | HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window" | |
| 126 | + | HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane" | |
| 127 | + | ) | |
| 128 | + | ||
| 129 | + | // BrowsingContextFailureCode is a stable machine-readable operational failure. | |
| 130 | + | type BrowsingContextFailureCode string | |
| 131 | + | ||
| 132 | + | const ( | |
| 133 | + | BrowsingContextFailureHostUnavailable BrowsingContextFailureCode = "host-unavailable" | |
| 134 | + | BrowsingContextFailureHostFailed BrowsingContextFailureCode = "host-failed" | |
| 135 | + | BrowsingContextFailureFeatureResolution BrowsingContextFailureCode = "feature-resolution-failed" | |
| 136 | + | BrowsingContextFailureStagingDetach BrowsingContextFailureCode = "staging-detach-failed" | |
| 137 | + | BrowsingContextFailureNativeArm BrowsingContextFailureCode = "native-arm-failed" | |
| 138 | + | BrowsingContextFailureFallback BrowsingContextFailureCode = "fallback-failed" | |
| 72 | 139 | ) | |
| 73 | 140 | ||
| 74 | 141 | // HostDecision represents the output of browsing-context policy. | |
| 75 | 142 | type HostDecision struct { | |
| 76 | - | Kind HostDecisionKind | |
| 143 | + | Kind HostDecisionKind | |
| 144 | + | SourceHost SourceHostKind | |
| 77 | 145 | ||
| 78 | 146 | ReuseContextName string | |
| 79 | 147 | BrowsingContextName string | |
| 80 | 148 | ||
| 81 | 149 | RequestContextDisposition RequestContextDisposition | |
| 82 | 150 | RequiresNativeOpener bool | |
| 151 | + | ReasonCode HostDecisionReasonCode | |
| 83 | 152 | Reason string | |
| 84 | 153 | } | |
M internal/application/port/mocks/webview.go +72 -0
| @@ -7,6 +7,7 @@ package mocks | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | mock "github.com/stretchr/testify/mock" | |
| 12 | 13 | ) | |
| @@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop | |||
| 2631 | 2632 | return _c | |
| 2632 | 2633 | } | |
| 2633 | 2634 | ||
| 2635 | + | // NewMockPopupFeatureResolver creates a new instance of MockPopupFeatureResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2636 | + | // The first argument is typically a *testing.T value. | |
| 2637 | + | func NewMockPopupFeatureResolver(t interface { | |
| 2638 | + | mock.TestingT | |
| 2639 | + | Cleanup(func()) | |
| 2640 | + | }) *MockPopupFeatureResolver { | |
| 2641 | + | mock := &MockPopupFeatureResolver{} | |
| 2642 | + | mock.Mock.Test(t) | |
| 2643 | + | ||
| 2644 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2645 | + | ||
| 2646 | + | return mock | |
| 2647 | + | } | |
| 2648 | + | ||
| 2649 | + | // MockPopupFeatureResolver is an autogenerated mock type for the PopupFeatureResolver type | |
| 2650 | + | type MockPopupFeatureResolver struct { | |
| 2651 | + | mock.Mock | |
| 2652 | + | } | |
| 2653 | + | ||
| 2654 | + | type MockPopupFeatureResolver_Expecter struct { | |
| 2655 | + | mock *mock.Mock | |
| 2656 | + | } | |
| 2657 | + | ||
| 2658 | + | func (_m *MockPopupFeatureResolver) EXPECT() *MockPopupFeatureResolver_Expecter { | |
| 2659 | + | return &MockPopupFeatureResolver_Expecter{mock: &_m.Mock} | |
| 2660 | + | } | |
| 2661 | + | ||
| 2662 | + | // ResolvePopupFeatures provides a mock function for the type MockPopupFeatureResolver | |
| 2663 | + | func (_mock *MockPopupFeatureResolver) ResolvePopupFeatures() dto.PopupFeatures { | |
| 2664 | + | ret := _mock.Called() | |
| 2665 | + | ||
| 2666 | + | if len(ret) == 0 { | |
| 2667 | + | panic("no return value specified for ResolvePopupFeatures") | |
| 2668 | + | } | |
| 2669 | + | ||
| 2670 | + | var r0 dto.PopupFeatures | |
| 2671 | + | if returnFunc, ok := ret.Get(0).(func() dto.PopupFeatures); ok { | |
| 2672 | + | r0 = returnFunc() | |
| 2673 | + | } else { | |
| 2674 | + | r0 = ret.Get(0).(dto.PopupFeatures) | |
| 2675 | + | } | |
| 2676 | + | return r0 | |
| 2677 | + | } | |
| 2678 | + | ||
| 2679 | + | // MockPopupFeatureResolver_ResolvePopupFeatures_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolvePopupFeatures' | |
| 2680 | + | type MockPopupFeatureResolver_ResolvePopupFeatures_Call struct { | |
| 2681 | + | *mock.Call | |
| 2682 | + | } | |
| 2683 | + | ||
| 2684 | + | // ResolvePopupFeatures is a helper method to define mock.On call | |
| 2685 | + | func (_e *MockPopupFeatureResolver_Expecter) ResolvePopupFeatures() *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2686 | + | return &MockPopupFeatureResolver_ResolvePopupFeatures_Call{Call: _e.mock.On("ResolvePopupFeatures")} | |
| 2687 | + | } | |
| 2688 | + | ||
| 2689 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Run(run func()) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2690 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2691 | + | run() | |
| 2692 | + | }) | |
| 2693 | + | return _c | |
| 2694 | + | } | |
| 2695 | + | ||
| 2696 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Return(popupFeatures dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2697 | + | _c.Call.Return(popupFeatures) | |
| 2698 | + | return _c | |
| 2699 | + | } | |
| 2700 | + | ||
| 2701 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) RunAndReturn(run func() dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2702 | + | _c.Call.Return(run) | |
| 2703 | + | return _c | |
| 2704 | + | } | |
| 2705 | + | ||
| 2634 | 2706 | // NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2635 | 2707 | // The first argument is typically a *testing.T value. | |
| 2636 | 2708 | func NewMockPopupOpenerCapable(t interface { | |
M internal/application/port/webview.go +8 -1
| @@ -77,8 +77,9 @@ type PopupRequest struct { | |||
| 77 | 77 | SourceFrameID string | |
| 78 | 78 | SourceFrameURL string | |
| 79 | 79 | ||
| 80 | + | Engine dto.BrowserEngineKind | |
| 80 | 81 | TargetDisposition dto.WindowDisposition | |
| 81 | - | WindowFeatures string | |
| 82 | + | PopupFeatures dto.PopupFeatures | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | 85 | // Texture represents a graphics texture returned by the engine. | |
| @@ -342,6 +343,12 @@ type PopupLifecycleCapable interface { | |||
| 342 | 343 | PrimePopupNavigation(uri string) | |
| 343 | 344 | } | |
| 344 | 345 | ||
| 346 | + | // PopupFeatureResolver is implemented by engines that can only resolve popup | |
| 347 | + | // window properties after the related WebView reaches ready-to-show. | |
| 348 | + | type PopupFeatureResolver interface { | |
| 349 | + | ResolvePopupFeatures() dto.PopupFeatures | |
| 350 | + | } | |
| 351 | + | ||
| 345 | 352 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | |
| 346 | 353 | // semantics when a popup must be created directly instead of through a native | |
| 347 | 354 | // related-popup lifecycle, and that can report or observe synthetic opener | |
A internal/application/usecase/browsing_context_policy.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // BrowsingContextPolicy selects the product host for a normalized browsing-context request. | |
| 10 | + | type BrowsingContextPolicy struct{} | |
| 11 | + | ||
| 12 | + | // Decide returns the host decision without performing any I/O. | |
| 13 | + | func (BrowsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision { | |
| 14 | + | decision := dto.HostDecision{ | |
| 15 | + | SourceHost: req.SourceHost, | |
| 16 | + | RequestContextDisposition: req.RequestContextDisposition, | |
| 17 | + | RequiresNativeOpener: req.RequiresNativeOpener, | |
| 18 | + | } | |
| 19 | + | if decision.RequestContextDisposition == "" { | |
| 20 | + | decision.RequestContextDisposition = dto.RequestContextInheritParent | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | switch { | |
| 24 | + | case strings.TrimSpace(req.TargetURI) == "": | |
| 25 | + | return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI") | |
| 26 | + | case req.AuthIntent: | |
| 27 | + | return completeHostDecision( | |
| 28 | + | decision, | |
| 29 | + | dto.HostDecisionCreateNativePopup, | |
| 30 | + | dto.HostDecisionReasonAuthNativePopup, | |
| 31 | + | "authentication intent requires a related native popup", | |
| 32 | + | ) | |
| 33 | + | case req.RequiresNativeOpener: | |
| 34 | + | return completeHostDecision( | |
| 35 | + | decision, | |
| 36 | + | dto.HostDecisionCreateNativePopup, | |
| 37 | + | dto.HostDecisionReasonNativeOpenerRequired, | |
| 38 | + | "native opener relationship is required", | |
| 39 | + | ) | |
| 40 | + | case isAmbiguousNativeBrowsingContext(req): | |
| 41 | + | return completeHostDecision( | |
| 42 | + | decision, | |
| 43 | + | dto.HostDecisionCreateNativePopup, | |
| 44 | + | dto.HostDecisionReasonAmbiguousOpenerNative, | |
| 45 | + | "ambiguous opener-coupled request prefers a related native popup", | |
| 46 | + | ) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | name := ReusableBrowsingContextName(req.TargetFrameName) | |
| 50 | + | if namedContextExists && name != "" && !req.NoJavaScriptAccess { | |
| 51 | + | decision.ReuseContextName = name | |
| 52 | + | decision.BrowsingContextName = name | |
| 53 | + | return completeHostDecision( | |
| 54 | + | decision, | |
| 55 | + | dto.HostDecisionReuseNamedPane, | |
| 56 | + | dto.HostDecisionReasonNamedContextReuse, | |
| 57 | + | "reuse live named browsing context", | |
| 58 | + | ) | |
| 59 | + | } | |
| 60 | + | decision.BrowsingContextName = name | |
| 61 | + | ||
| 62 | + | if req.SourceHost == dto.SourceHostFloating { | |
| 63 | + | return decideFloatingHost(decision, req) | |
| 64 | + | } | |
| 65 | + | return completeHostDecision( | |
| 66 | + | decision, | |
| 67 | + | dto.HostDecisionCreatePane, | |
| 68 | + | dto.HostDecisionReasonWorkspacePane, | |
| 69 | + | "workspace request uses pane hosting", | |
| 70 | + | ) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision { | |
| 74 | + | switch { | |
| 75 | + | case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage: | |
| 76 | + | return completeHostDecision( | |
| 77 | + | decision, | |
| 78 | + | dto.HostDecisionAwaitPopupFeatures, | |
| 79 | + | dto.HostDecisionReasonFloatingFeaturesPending, | |
| 80 | + | "popup features unavailable until ready-to-show", | |
| 81 | + | ) | |
| 82 | + | case popupFeaturesRequestHost(req.PopupFeatures): | |
| 83 | + | return completeHostDecision( | |
| 84 | + | decision, | |
| 85 | + | dto.HostDecisionCreateNativePopup, | |
| 86 | + | dto.HostDecisionReasonFloatingPopupFeatures, | |
| 87 | + | "explicit popup features requested", | |
| 88 | + | ) | |
| 89 | + | case ReusableBrowsingContextName(req.TargetFrameName) == "": | |
| 90 | + | return completeHostDecision( | |
| 91 | + | decision, | |
| 92 | + | dto.HostDecisionNavigateSource, | |
| 93 | + | dto.HostDecisionReasonFloatingFeaturelessBlank, | |
| 94 | + | "featureless blank request navigates the source floating pane", | |
| 95 | + | ) | |
| 96 | + | default: | |
| 97 | + | return completeHostDecision( | |
| 98 | + | decision, | |
| 99 | + | dto.HostDecisionCreateBrowserWindow, | |
| 100 | + | dto.HostDecisionReasonFloatingBrowserWindow, | |
| 101 | + | "floating request opens a detached browser window", | |
| 102 | + | ) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func popupFeaturesRequestHost(features dto.PopupFeatures) bool { | |
| 107 | + | return features.State == dto.PopupFeaturesSpecified && | |
| 108 | + | ((features.WidthSet && features.Width > 0) || | |
| 109 | + | (features.HeightSet && features.Height > 0) || | |
| 110 | + | (features.ToolbarVisibilitySet && !features.ToolbarVisible) || | |
| 111 | + | (features.LocationbarVisibilitySet && !features.LocationbarVisible) || | |
| 112 | + | (features.ResizableSet && !features.Resizable) || | |
| 113 | + | (features.IsPopupSet && features.IsPopup)) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func completeHostDecision( | |
| 117 | + | decision dto.HostDecision, | |
| 118 | + | kind dto.HostDecisionKind, | |
| 119 | + | code dto.HostDecisionReasonCode, | |
| 120 | + | reason string, | |
| 121 | + | ) dto.HostDecision { | |
| 122 | + | decision.Kind = kind | |
| 123 | + | decision.ReasonCode = code | |
| 124 | + | decision.Reason = reason | |
| 125 | + | return decision | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool { | |
| 129 | + | return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // ReusableBrowsingContextName returns a live-context key for named targets. | |
| 133 | + | func ReusableBrowsingContextName(frameName string) string { | |
| 134 | + | name := strings.TrimSpace(frameName) | |
| 135 | + | if name == "" || strings.EqualFold(name, "_blank") { | |
| 136 | + | return "" | |
| 137 | + | } | |
| 138 | + | return name | |
| 139 | + | } | |
A internal/application/usecase/browsing_context_policy_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestPopupFeaturesRequestHost(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | features dto.PopupFeatures | |
| 16 | + | want bool | |
| 17 | + | }{ | |
| 18 | + | {name: "known featureless", features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, | |
| 19 | + | {name: "zero width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 0, WidthSet: true}}, | |
| 20 | + | {name: "positive width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 1, WidthSet: true}, want: true}, | |
| 21 | + | {name: "positive height", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Height: 1, HeightSet: true}, want: true}, | |
| 22 | + | {name: "hidden toolbar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: false, ToolbarVisibilitySet: true}, want: true}, | |
| 23 | + | {name: "hidden location bar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, LocationbarVisible: false, LocationbarVisibilitySet: true}, want: true}, | |
| 24 | + | {name: "disabled resize", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: false, ResizableSet: true}, want: true}, | |
| 25 | + | {name: "engine popup flag", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true}, want: true}, | |
| 26 | + | {name: "visible chrome", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: true, ToolbarVisibilitySet: true}}, | |
| 27 | + | {name: "resizable", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: true, ResizableSet: true}}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | t.Parallel() | |
| 33 | + | assert.Equal(t, tt.want, popupFeaturesRequestHost(tt.features)) | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestBrowsingContextPolicyDecide(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 40 | + | ||
| 41 | + | workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage} | |
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | request dto.NewBrowsingContextRequest | |
| 45 | + | namedContextExist bool | |
| 46 | + | wantKind dto.HostDecisionKind | |
| 47 | + | wantName string | |
| 48 | + | wantReason dto.HostDecisionReasonCode | |
| 49 | + | }{ | |
| 50 | + | {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | |
| 51 | + | {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | |
| 52 | + | {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, | |
| 53 | + | {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, | |
| 54 | + | {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures}, | |
| 55 | + | {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | |
| 56 | + | {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | |
| 57 | + | {name: "auth intent precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/auth", AuthIntent: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAuthNativePopup}, | |
| 58 | + | {name: "native opener precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/open", RequiresNativeOpener: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonNativeOpenerRequired}, | |
| 59 | + | {name: "ambiguous workspace opener stays native", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/ambiguous", TriggerKind: dto.TriggerUnknown}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAmbiguousOpenerNative}, | |
| 60 | + | {name: "floating live named context is reused", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionReuseNamedPane, wantName: "shared", wantReason: dto.HostDecisionReasonNamedContextReuse}, | |
| 61 | + | {name: "floating noopener skips named reuse", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, NoJavaScriptAccess: true, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionCreateBrowserWindow, wantName: "shared", wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | |
| 62 | + | {name: "empty target is denied first", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, AuthIntent: true}, wantKind: dto.HostDecisionDeny, wantReason: dto.HostDecisionReasonEmptyTarget}, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | for _, tt := range tests { | |
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |
| 67 | + | t.Parallel() | |
| 68 | + | got := (BrowsingContextPolicy{}).Decide(tt.request, tt.namedContextExist) | |
| 69 | + | assert.Equal(t, tt.wantKind, got.Kind) | |
| 70 | + | assert.Equal(t, tt.wantName, got.BrowsingContextName) | |
| 71 | + | assert.Equal(t, tt.wantReason, got.ReasonCode) | |
| 72 | + | assert.NotEmpty(t, got.Reason) | |
| 73 | + | assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition) | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
M internal/infrastructure/cef/first_presentation_collector_test.go +6 -2
| @@ -118,6 +118,9 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 118 | 118 | binary := filepath.Join(temp, "dumber") | |
| 119 | 119 | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |
| 120 | 120 | ||
| 121 | + | const upstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe" | |
| 122 | + | const upstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484" | |
| 123 | + | goBin := collectorProvenanceGo(t, temp, upstreamVersion, upstreamRevision, "") | |
| 121 | 124 | output := filepath.Join(temp, "artifacts") | |
| 122 | 125 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 123 | 126 | cmd.Dir = repoRoot | |
| @@ -129,6 +132,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 129 | 132 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |
| 130 | 133 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |
| 131 | 134 | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | |
| 135 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |
| 132 | 136 | ) | |
| 133 | 137 | result, err := cmd.CombinedOutput() | |
| 134 | 138 | require.NoErrorf(t, err, "collector failed: %s", result) | |
| @@ -144,9 +148,9 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 144 | 148 | } | |
| 145 | 149 | for _, required := range []string{ | |
| 146 | 150 | `"measured_source_revision"`, | |
| 147 | - | `"version": "v0.8.5"`, | |
| 151 | + | `"version": "` + upstreamVersion + `"`, | |
| 148 | 152 | `"tag": "v0.8.5"`, | |
| 149 | - | `"revision": "b9fb46ef5c04ca0993943c5ca4b6f0fe83583304"`, | |
| 153 | + | `"revision": "` + upstreamRevision + `"`, | |
| 150 | 154 | } { | |
| 151 | 155 | require.Contains(t, artifacts.String(), required) | |
| 152 | 156 | } | |
M internal/infrastructure/cef/webview.go +24 -5
| @@ -172,6 +172,7 @@ type WebView struct { | |||
| 172 | 172 | // Programmatic popup lifecycle callbacks used for OAuth auto-close and | |
| 173 | 173 | // synthetic window.open() proxy support on CEF. | |
| 174 | 174 | closeCallbacks []func() | |
| 175 | + | popupLifecycleClose func() | |
| 175 | 176 | navigationCallbacks []func(string) | |
| 176 | 177 | openerMessageCallbacks []func() | |
| 177 | 178 | openerNavigationCallbacks []func(string) | |
| @@ -653,9 +654,15 @@ func (wv *WebView) SetOnReadyToShow(fn func()) { | |||
| 653 | 654 | } | |
| 654 | 655 | } | |
| 655 | 656 | ||
| 656 | - | // SetOnClose implements port.PopupLifecycleCapable. | |
| 657 | + | // SetOnClose replaces the coordinator-owned popup lifecycle callback without | |
| 658 | + | // disturbing OAuth and other additive close callbacks. | |
| 657 | 659 | func (wv *WebView) SetOnClose(fn func()) { | |
| 658 | - | wv.AddCloseCallback(fn) | |
| 660 | + | if wv == nil { | |
| 661 | + | return | |
| 662 | + | } | |
| 663 | + | wv.mu.Lock() | |
| 664 | + | wv.popupLifecycleClose = fn | |
| 665 | + | wv.mu.Unlock() | |
| 659 | 666 | } | |
| 660 | 667 | ||
| 661 | 668 | // Show implements port.PopupLifecycleCapable. | |
| @@ -823,15 +830,23 @@ func (wv *WebView) SetNativePopupHostAbort(fn func()) { | |||
| 823 | 830 | } | |
| 824 | 831 | ||
| 825 | 832 | func (wv *WebView) AbortNativePopupHost() { | |
| 833 | + | wv.abortNativePopupHost() | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | // abortNativePopupHost reports whether an installed host callback took | |
| 837 | + | // responsibility for either transferring or destroying the popup WebView. | |
| 838 | + | func (wv *WebView) abortNativePopupHost() bool { | |
| 826 | 839 | if wv == nil { | |
| 827 | - | return | |
| 840 | + | return false | |
| 828 | 841 | } | |
| 829 | 842 | wv.mu.RLock() | |
| 830 | 843 | fn := wv.nativePopupHostAbort | |
| 831 | 844 | wv.mu.RUnlock() | |
| 832 | - | if fn != nil { | |
| 833 | - | fn() | |
| 845 | + | if fn == nil { | |
| 846 | + | return false | |
| 834 | 847 | } | |
| 848 | + | fn() | |
| 849 | + | return true | |
| 835 | 850 | } | |
| 836 | 851 | ||
| 837 | 852 | func (wv *WebView) awaitsNativePopupAttachment() bool { | |
| @@ -2291,6 +2306,10 @@ func (wv *WebView) runCloseCallbacks() { | |||
| 2291 | 2306 | wv.mu.Lock() | |
| 2292 | 2307 | callbacks := append([]func(){}, wv.closeCallbacks...) | |
| 2293 | 2308 | wv.closeCallbacks = nil | |
| 2309 | + | if wv.popupLifecycleClose != nil { | |
| 2310 | + | callbacks = append(callbacks, wv.popupLifecycleClose) | |
| 2311 | + | wv.popupLifecycleClose = nil | |
| 2312 | + | } | |
| 2294 | 2313 | wv.mu.Unlock() | |
| 2295 | 2314 | if len(callbacks) == 0 { | |
| 2296 | 2315 | return | |
M internal/infrastructure/cef/webview_handlers.go +96 -13
| @@ -587,6 +587,30 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCo | |||
| 587 | 587 | } | |
| 588 | 588 | } | |
| 589 | 589 | ||
| 590 | + | func mapCEFPopupFeatures(in *purecef.PopupFeatures) dto.PopupFeatures { | |
| 591 | + | if in == nil { | |
| 592 | + | return dto.PopupFeatures{State: dto.PopupFeaturesNone} | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | state := dto.PopupFeaturesNone | |
| 596 | + | if in.Xset != 0 || in.Yset != 0 || in.Widthset != 0 || in.Heightset != 0 || in.Ispopup != 0 { | |
| 597 | + | state = dto.PopupFeaturesSpecified | |
| 598 | + | } | |
| 599 | + | return dto.PopupFeatures{ | |
| 600 | + | State: state, | |
| 601 | + | X: int(in.X), | |
| 602 | + | Y: int(in.Y), | |
| 603 | + | Width: int(in.Width), | |
| 604 | + | Height: int(in.Height), | |
| 605 | + | XSet: in.Xset != 0, | |
| 606 | + | YSet: in.Yset != 0, | |
| 607 | + | WidthSet: in.Widthset != 0, | |
| 608 | + | HeightSet: in.Heightset != 0, | |
| 609 | + | IsPopup: in.Ispopup != 0, | |
| 610 | + | IsPopupSet: true, | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | ||
| 590 | 614 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | |
| 591 | 615 | switch disposition { | |
| 592 | 616 | case purecef.WindowOpenDispositionWodCurrentTab: | |
| @@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind | |||
| 616 | 640 | // pane handle the navigation. | |
| 617 | 641 | func (h *handlerSet) OnBeforePopup( | |
| 618 | 642 | browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string, | |
| 619 | - | targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | |
| 643 | + | targetDisposition purecef.WindowOpenDisposition, userGesture int32, popupFeatures *purecef.PopupFeatures, | |
| 620 | 644 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | |
| 621 | 645 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | |
| 622 | 646 | ) bool { | |
| @@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup( | |||
| 649 | 673 | ||
| 650 | 674 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | |
| 651 | 675 | popup = cb.OnCreate(port.PopupRequest{ | |
| 676 | + | Engine: dto.BrowserEngineCEF, | |
| 677 | + | PopupFeatures: mapCEFPopupFeatures(popupFeatures), | |
| 652 | 678 | TargetURI: targetURL, | |
| 653 | 679 | FrameName: targetFrameName, | |
| 654 | 680 | IsUserGesture: userGesture != 0, | |
| @@ -682,9 +708,33 @@ func (h *handlerSet) OnBeforePopup( | |||
| 682 | 708 | } | |
| 683 | 709 | } | |
| 684 | 710 | ||
| 711 | + | return h.handlePopupHostDecision( | |
| 712 | + | cefPopup, | |
| 713 | + | decision, | |
| 714 | + | requestNoJavaScriptAccess, | |
| 715 | + | popupID, | |
| 716 | + | targetURL, | |
| 717 | + | targetDisposition, | |
| 718 | + | windowInfo, | |
| 719 | + | clientSlot, | |
| 720 | + | settings, | |
| 721 | + | ) | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | func (h *handlerSet) handlePopupHostDecision( | |
| 725 | + | cefPopup *WebView, | |
| 726 | + | decision dto.HostDecision, | |
| 727 | + | requestNoJavaScriptAccess bool, | |
| 728 | + | popupID int32, | |
| 729 | + | targetURL string, | |
| 730 | + | targetDisposition purecef.WindowOpenDisposition, | |
| 731 | + | windowInfo *purecef.WindowInfo, | |
| 732 | + | clientSlot *purecef.RawClientWriteSlot, | |
| 733 | + | settings *purecef.BrowserSettings, | |
| 734 | + | ) bool { | |
| 685 | 735 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | |
| 686 | 736 | switch decision.Kind { | |
| 687 | - | case dto.HostDecisionCreateNativeWin: | |
| 737 | + | case dto.HostDecisionCreateNativePopup: | |
| 688 | 738 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | |
| 689 | 739 | logging.FromContext(h.currentContext()).Debug(). | |
| 690 | 740 | Int32("popup_id", popupID). | |
| @@ -692,32 +742,65 @@ func (h *handlerSet) OnBeforePopup( | |||
| 692 | 742 | Msg("cef: native popup armed") | |
| 693 | 743 | return false | |
| 694 | 744 | } | |
| 695 | - | if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok { | |
| 696 | - | aborter.AbortNativePopupHost() | |
| 745 | + | ||
| 746 | + | fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup | |
| 747 | + | if fallbackEligible { | |
| 748 | + | // The Phase 3 abort callback may synchronously detach and adopt this | |
| 749 | + | // shell. Arm direct creation first so the adopted shell can create a | |
| 750 | + | // browser and retain the synthetic opener bridge when allowed. | |
| 751 | + | cefPopup.preparePopupShellDirectBrowserCreation() | |
| 752 | + | } | |
| 753 | + | abortInvoked := cefPopup.abortNativePopupHost() | |
| 754 | + | if !abortInvoked { | |
| 755 | + | cefPopup.Destroy() | |
| 697 | 756 | } | |
| 698 | - | cefPopup.discardNativePopupCandidate() | |
| 699 | 757 | logging.FromContext(h.currentContext()).Warn(). | |
| 758 | + | Str("engine", string(dto.BrowserEngineCEF)). | |
| 759 | + | Str("source_host", string(decision.SourceHost)). | |
| 760 | + | Str("decision", string(decision.Kind)). | |
| 761 | + | Str("target_disposition", string(mapCEFWindowDisposition(targetDisposition))). | |
| 762 | + | Str("reason_code", string(dto.BrowsingContextFailureNativeArm)). | |
| 700 | 763 | Int32("popup_id", popupID). | |
| 701 | 764 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 702 | - | Msg("cef: native popup arming failed, denying without fallback") | |
| 765 | + | Bool("fallback_eligible", fallbackEligible). | |
| 766 | + | Bool("host_abort_invoked", abortInvoked). | |
| 767 | + | Msg("cef: native popup arming failed") | |
| 703 | 768 | return true | |
| 704 | - | case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane: | |
| 769 | + | case dto.HostDecisionCreateBrowserWindow, | |
| 770 | + | dto.HostDecisionCreatePane, | |
| 771 | + | dto.HostDecisionReuseNamedPane: | |
| 772 | + | // CEF's native popup is blocked. A related shell adopted by a Dumber | |
| 773 | + | // host must create its browser directly; noopener controls whether the | |
| 774 | + | // synthetic opener/postMessage bridge is installed. | |
| 775 | + | directCreationPrepared := cefPopup.preparePopupShellDirectBrowserCreation() | |
| 776 | + | if !directCreationPrepared && cefPopup.isNativePopupCandidate() { | |
| 777 | + | // Clear only a still-unprepared candidate; an already-started fallback | |
| 778 | + | // owns its opener state and must remain intact. | |
| 779 | + | cefPopup.discardNativePopupCandidate() | |
| 780 | + | } | |
| 705 | 781 | logging.FromContext(h.currentContext()).Debug(). | |
| 706 | 782 | Int32("popup_id", popupID). | |
| 707 | 783 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 708 | 784 | Str("decision", string(decision.Kind)). | |
| 709 | - | Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation") | |
| 785 | + | Bool("direct_creation_prepared", directCreationPrepared). | |
| 786 | + | Msg("cef: blocking native popup for related shell browser creation") | |
| 787 | + | return true | |
| 788 | + | case dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionDeny: | |
| 789 | + | cefPopup.Destroy() | |
| 790 | + | logging.FromContext(h.currentContext()).Warn(). | |
| 791 | + | Int32("popup_id", popupID). | |
| 792 | + | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 793 | + | Str("decision", string(decision.Kind)). | |
| 794 | + | Msg("cef: denied popup host decision") | |
| 710 | 795 | return true | |
| 711 | 796 | default: | |
| 712 | - | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | |
| 713 | - | return false | |
| 797 | + | if !cefPopup.preparePopupShellDirectBrowserCreation() { | |
| 798 | + | cefPopup.discardNativePopupCandidate() | |
| 714 | 799 | } | |
| 715 | - | ||
| 716 | - | cefPopup.discardNativePopupCandidate() | |
| 717 | 800 | logging.FromContext(h.currentContext()).Debug(). | |
| 718 | 801 | Int32("popup_id", popupID). | |
| 719 | 802 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 720 | - | Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation") | |
| 803 | + | Msg("cef: missing host decision, blocking native popup and using popup shell browser creation") | |
| 721 | 804 | return true | |
| 722 | 805 | } | |
| 723 | 806 | } | |
M internal/infrastructure/cef/webview_handlers_test.go +2 -115
| @@ -3,7 +3,6 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | - | "sync/atomic" | |
| 7 | 6 | "testing" | |
| 8 | 7 | "time" | |
| 9 | 8 | ||
| @@ -229,6 +228,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe | |||
| 229 | 228 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | |
| 230 | 229 | require.True(t, ok) | |
| 231 | 230 | require.NotNil(t, rawClient) | |
| 231 | + | require.Equal(t, int32(55), wv.nativePopupID) | |
| 232 | + | require.Same(t, parent, wv.nativePopupParent) | |
| 232 | 233 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | |
| 233 | 234 | ||
| 234 | 235 | wv.updateURI("about:blank") | |
| @@ -262,120 +263,6 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||
| 262 | 263 | require.True(t, wv.isLoading) | |
| 263 | 264 | } | |
| 264 | 265 | ||
| 265 | - | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |
| 266 | - | delayed := make(chan func(), 1) | |
| 267 | - | parentWV := &WebView{ | |
| 268 | - | ctx: context.Background(), | |
| 269 | - | id: 16, | |
| 270 | - | gtkSyncTimeout: 5 * time.Millisecond, | |
| 271 | - | gtkSyncIsOwner: func() bool { return false }, | |
| 272 | - | gtkSyncDispatch: func(fn func()) { | |
| 273 | - | delayed <- fn | |
| 274 | - | }, | |
| 275 | - | } | |
| 276 | - | var createCalls atomic.Int32 | |
| 277 | - | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 278 | - | OnCreate: func(_ port.PopupRequest) port.WebView { | |
| 279 | - | createCalls.Add(1) | |
| 280 | - | return &WebView{ctx: context.Background(), id: 24} | |
| 281 | - | }, | |
| 282 | - | }) | |
| 283 | - | ||
| 284 | - | h := &handlerSet{wv: parentWV} | |
| 285 | - | blocked := h.OnBeforePopup(nil, nil, 90, "https://example.com/slow-popup", "slow-popup", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 286 | - | ||
| 287 | - | require.True(t, blocked) | |
| 288 | - | require.Zero(t, createCalls.Load()) | |
| 289 | - | ||
| 290 | - | select { | |
| 291 | - | case fn := <-delayed: | |
| 292 | - | fn() | |
| 293 | - | case <-time.After(time.Second): | |
| 294 | - | t.Fatal("timed out waiting for delayed popup dispatch") | |
| 295 | - | } | |
| 296 | - | require.Zero(t, createCalls.Load()) | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) { | |
| 300 | - | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 301 | - | popupWV := &WebView{ctx: context.Background(), id: 23} | |
| 302 | - | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 303 | - | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 304 | - | require.Equal(t, "https://example.com/popup", req.TargetURI) | |
| 305 | - | require.Equal(t, "auth-popup", req.FrameName) | |
| 306 | - | require.True(t, req.IsUserGesture) | |
| 307 | - | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 308 | - | return popupWV | |
| 309 | - | }, | |
| 310 | - | }) | |
| 311 | - | ||
| 312 | - | h := &handlerSet{wv: parentWV} | |
| 313 | - | blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 314 | - | ||
| 315 | - | require.True(t, blocked) | |
| 316 | - | require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI()) | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) { | |
| 320 | - | parentWV := &WebView{ctx: context.Background(), id: 31} | |
| 321 | - | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 322 | - | popupWV.markNativePopupCandidate(parentWV) | |
| 323 | - | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 324 | - | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 325 | - | require.Equal(t, "https://example.com/login", req.TargetURI) | |
| 326 | - | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 327 | - | return popupWV | |
| 328 | - | }, | |
| 329 | - | }) | |
| 330 | - | ||
| 331 | - | h := &handlerSet{wv: parentWV} | |
| 332 | - | blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 333 | - | ||
| 334 | - | require.True(t, blocked) | |
| 335 | - | require.False(t, popupWV.isNativePopupCandidate()) | |
| 336 | - | require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI()) | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) { | |
| 340 | - | parentWV := &WebView{ctx: context.Background(), id: 41} | |
| 341 | - | popupWV := &WebView{ctx: context.Background(), id: 42} | |
| 342 | - | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane}) | |
| 343 | - | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 344 | - | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 345 | - | popupWV.PreparePaneHostedBrowsingContext() | |
| 346 | - | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 347 | - | return popupWV | |
| 348 | - | }, | |
| 349 | - | }) | |
| 350 | - | ||
| 351 | - | h := &handlerSet{wv: parentWV} | |
| 352 | - | blocked := h.OnBeforePopup(nil, nil, 81, "https://example.com/pane", "_blank", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 353 | - | ||
| 354 | - | require.True(t, blocked) | |
| 355 | - | require.Equal(t, "https://example.com/pane", popupWV.pendingNavigationURI()) | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) { | |
| 359 | - | parentWV := &WebView{ctx: context.Background(), id: 51} | |
| 360 | - | popupWV := &WebView{ctx: context.Background(), id: 52} | |
| 361 | - | popupWV.markNativePopupCandidate(parentWV) | |
| 362 | - | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | |
| 363 | - | aborted := false | |
| 364 | - | popupWV.SetNativePopupHostAbort(func() { aborted = true }) | |
| 365 | - | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 366 | - | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 367 | - | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 368 | - | return popupWV | |
| 369 | - | }, | |
| 370 | - | }) | |
| 371 | - | ||
| 372 | - | h := &handlerSet{wv: parentWV} | |
| 373 | - | blocked := h.OnBeforePopup(nil, nil, 82, "https://accounts.google.com/o/oauth2/v2/auth", "oauth", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 374 | - | ||
| 375 | - | require.True(t, blocked) | |
| 376 | - | require.True(t, aborted) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | 266 | func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) { | |
| 380 | 267 | windowInfo := purecef.NewWindowInfo() | |
| 381 | 268 | settings := purecef.NewBrowserSettings() | |
A internal/infrastructure/cef/webview_popup_lifecycle_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) { | |
| 10 | + | wv := &WebView{inputAttached: true} | |
| 11 | + | readyCalls := 0 | |
| 12 | + | lifecycleCloseCalls := 0 | |
| 13 | + | oauthCloseCalls := 0 | |
| 14 | + | wv.SetOnReadyToShow(func() { readyCalls++ }) | |
| 15 | + | wv.SetOnClose(func() { lifecycleCloseCalls++ }) | |
| 16 | + | wv.AddCloseCallback(func() { oauthCloseCalls++ }) | |
| 17 | + | ||
| 18 | + | wv.SetOnReadyToShow(nil) | |
| 19 | + | wv.SetOnClose(nil) | |
| 20 | + | wv.fireReadyToShow() | |
| 21 | + | wv.runCloseCallbacks() | |
| 22 | + | ||
| 23 | + | assert.Zero(t, readyCalls) | |
| 24 | + | assert.Zero(t, lifecycleCloseCalls) | |
| 25 | + | assert.Equal(t, 1, oauthCloseCalls) | |
| 26 | + | } | |
A internal/infrastructure/cef/webview_popup_routing_test.go +483 -0
| @@ -0,0 +1,483 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | + | "github.com/rs/zerolog" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 17 | + | "github.com/bnema/dumber/internal/application/port" | |
| 18 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 19 | + | "github.com/bnema/dumber/internal/logging" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func TestMapCEFPopupFeatures(t *testing.T) { | |
| 23 | + | t.Parallel() | |
| 24 | + | ||
| 25 | + | tests := []struct { | |
| 26 | + | name string | |
| 27 | + | in *purecef.PopupFeatures | |
| 28 | + | want dto.PopupFeatures | |
| 29 | + | }{ | |
| 30 | + | { | |
| 31 | + | name: "nil is known featureless", | |
| 32 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "positive width is popup-like", | |
| 36 | + | in: &purecef.PopupFeatures{Width: 640, Widthset: 1}, | |
| 37 | + | want: dto.PopupFeatures{ | |
| 38 | + | State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true, IsPopupSet: true, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "geometry and flags are normalized", | |
| 43 | + | in: &purecef.PopupFeatures{ | |
| 44 | + | X: 10, Xset: 1, Y: 20, Yset: 1, | |
| 45 | + | Width: 800, Widthset: 1, Height: 600, Heightset: 1, | |
| 46 | + | }, | |
| 47 | + | want: dto.PopupFeatures{ | |
| 48 | + | State: dto.PopupFeaturesSpecified, | |
| 49 | + | X: 10, XSet: true, Y: 20, YSet: true, | |
| 50 | + | Width: 800, WidthSet: true, Height: 600, HeightSet: true, | |
| 51 | + | IsPopupSet: true, | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "is popup flag survives without inventing geometry", | |
| 56 | + | in: &purecef.PopupFeatures{Ispopup: 1}, | |
| 57 | + | want: dto.PopupFeatures{ | |
| 58 | + | State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true, | |
| 59 | + | }, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | name: "allocated defaults are known featureless", | |
| 63 | + | in: &purecef.PopupFeatures{}, | |
| 64 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone, IsPopupSet: true}, | |
| 65 | + | }, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | for _, tt := range tests { | |
| 69 | + | t.Run(tt.name, func(t *testing.T) { | |
| 70 | + | got := mapCEFPopupFeatures(tt.in) | |
| 71 | + | assert.Equal(t, tt.want, got) | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestOnBeforePopup_PropagatesCEFEngineFeaturesGestureAndNoJavaScriptAccess(t *testing.T) { | |
| 77 | + | parentWV := &WebView{ctx: context.Background(), id: 16} | |
| 78 | + | popupWV := &WebView{ctx: context.Background(), id: 24, pendingCreate: &pendingBrowserCreate{}} | |
| 79 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 80 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 81 | + | noJavaScriptAccess := true | |
| 82 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 83 | + | assert.Equal(t, dto.BrowserEngineCEF, req.Engine) | |
| 84 | + | assert.Equal(t, dto.PopupFeatures{ | |
| 85 | + | State: dto.PopupFeaturesSpecified, X: 10, XSet: true, Width: 640, WidthSet: true, | |
| 86 | + | IsPopup: true, IsPopupSet: true, | |
| 87 | + | }, req.PopupFeatures) | |
| 88 | + | assert.Equal(t, dto.WindowDispositionNewPopup, req.TargetDisposition) | |
| 89 | + | assert.True(t, req.IsUserGesture) | |
| 90 | + | assert.True(t, req.NoJavaScriptAccess) | |
| 91 | + | return popupWV | |
| 92 | + | }}) | |
| 93 | + | ||
| 94 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 95 | + | nil, nil, 90, "https://example.com/popup", "named-popup", | |
| 96 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 97 | + | &purecef.PopupFeatures{X: 10, Xset: 1, Width: 640, Widthset: 1, Ispopup: 1}, | |
| 98 | + | nil, nil, nil, nil, &noJavaScriptAccess, | |
| 99 | + | ) | |
| 100 | + | ||
| 101 | + | require.True(t, blocked) | |
| 102 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 103 | + | require.Nil(t, popupWV.popupOpenerBridgeParent, "noopener must not install the synthetic opener bridge") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestOnBeforePopup_FeaturelessBlankNavigateSourceBlocksNativePopup(t *testing.T) { | |
| 107 | + | const targetURI = "https://example.com/source-navigation" | |
| 108 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 109 | + | candidate := portmocks.NewMockWebView(t) | |
| 110 | + | candidate.EXPECT().Destroy().Once() | |
| 111 | + | ||
| 112 | + | var callbackResult port.WebView = candidate | |
| 113 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 114 | + | require.Equal(t, dto.BrowserEngineCEF, req.Engine) | |
| 115 | + | require.Equal(t, "_blank", req.FrameName) | |
| 116 | + | require.Equal(t, dto.WindowDispositionNewTab, req.TargetDisposition) | |
| 117 | + | require.Equal(t, dto.PopupFeaturesNone, req.PopupFeatures.State) | |
| 118 | + | ||
| 119 | + | // NavigateSource ownership belongs to the coordinator callback: navigate | |
| 120 | + | // the source, destroy its unused candidate exactly once, then return nil. | |
| 121 | + | require.NoError(t, parentWV.LoadURI(context.Background(), req.TargetURI)) | |
| 122 | + | candidate.Destroy() | |
| 123 | + | callbackResult = nil | |
| 124 | + | return callbackResult | |
| 125 | + | }}) | |
| 126 | + | ||
| 127 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 128 | + | nil, nil, 91, targetURI, "_blank", | |
| 129 | + | purecef.WindowOpenDispositionWodNewForegroundTab, 1, nil, nil, nil, nil, nil, nil, | |
| 130 | + | ) | |
| 131 | + | ||
| 132 | + | require.True(t, blocked, "CEF native popup creation must remain blocked") | |
| 133 | + | require.Nil(t, callbackResult, "NavigateSource callback must return nil") | |
| 134 | + | require.Equal(t, targetURI, parentWV.pendingNavigationURI()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestOnBeforePopup_BrowserWindowDecisionPreservesSyntheticOpenerBridge(t *testing.T) { | |
| 138 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 139 | + | parentWV.updateURI("https://example.com/opener") | |
| 140 | + | popupWV := &WebView{ctx: context.Background(), id: 25, pendingCreate: &pendingBrowserCreate{}} | |
| 141 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 142 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 143 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 144 | + | ||
| 145 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 146 | + | nil, nil, 91, "https://example.com/child", "child", | |
| 147 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 148 | + | ) | |
| 149 | + | ||
| 150 | + | require.True(t, blocked) | |
| 151 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 152 | + | require.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |
| 153 | + | require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *testing.T) { | |
| 157 | + | parentWV := &WebView{ctx: context.Background(), id: 18} | |
| 158 | + | parentWV.updateURI("https://example.com/opener") | |
| 159 | + | popupWV := &WebView{ctx: context.Background(), id: 26, pendingCreate: &pendingBrowserCreate{}} | |
| 160 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 161 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 162 | + | abortCalls := 0 | |
| 163 | + | popupWV.SetNativePopupHostAbort(func() { | |
| 164 | + | abortCalls++ | |
| 165 | + | assert.True(t, popupWV.nativePopupFallbackStarted) | |
| 166 | + | assert.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |
| 167 | + | }) | |
| 168 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 169 | + | ||
| 170 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 171 | + | nil, nil, 92, "https://example.com/visual", "visual", | |
| 172 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 173 | + | &purecef.PopupFeatures{Width: 640, Widthset: 1}, nil, nil, nil, nil, nil, | |
| 174 | + | ) | |
| 175 | + | ||
| 176 | + | require.True(t, blocked) | |
| 177 | + | require.Equal(t, 1, abortCalls) | |
| 178 | + | require.False(t, popupWV.IsDestroyed()) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |
| 182 | + | var output bytes.Buffer | |
| 183 | + | logger := zerolog.New(&output).Level(zerolog.WarnLevel) | |
| 184 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 185 | + | parentWV := &WebView{ctx: ctx, id: 19} | |
| 186 | + | popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}} | |
| 187 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 188 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 189 | + | Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true, | |
| 190 | + | }) | |
| 191 | + | abortCalls := 0 | |
| 192 | + | popupWV.SetNativePopupHostAbort(func() { | |
| 193 | + | abortCalls++ | |
| 194 | + | popupWV.Destroy() | |
| 195 | + | }) | |
| 196 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 197 | + | ||
| 198 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 199 | + | nil, nil, 93, "https://example.com/opener-required", "required", | |
| 200 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 201 | + | ) | |
| 202 | + | ||
| 203 | + | require.True(t, blocked) | |
| 204 | + | require.Equal(t, 1, abortCalls) | |
| 205 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 206 | + | require.Nil(t, popupWV.popupOpenerBridgeParent) | |
| 207 | + | require.True(t, popupWV.IsDestroyed()) | |
| 208 | + | var record map[string]any | |
| 209 | + | require.NoError(t, json.Unmarshal(output.Bytes(), &record)) | |
| 210 | + | assert.Equal(t, "cef", record["engine"]) | |
| 211 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 212 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 213 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 214 | + | assert.Equal(t, "native-arm-failed", record["reason_code"]) | |
| 215 | + | assert.Equal(t, true, record["host_abort_invoked"]) | |
| 216 | + | assert.Equal(t, "cef: native popup arming failed", record["message"]) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestOnBeforePopup_NativeArmFailureWithoutHostAbortLogsDirectCleanup(t *testing.T) { | |
| 220 | + | var output bytes.Buffer | |
| 221 | + | logger := zerolog.New(&output).Level(zerolog.WarnLevel) | |
| 222 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 223 | + | parentWV := &WebView{ctx: ctx, id: 20} | |
| 224 | + | popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}} | |
| 225 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 226 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 227 | + | Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true, | |
| 228 | + | }) | |
| 229 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 230 | + | ||
| 231 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 232 | + | nil, nil, 94, "https://example.com/opener-required", "required", | |
| 233 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 234 | + | ) | |
| 235 | + | ||
| 236 | + | require.True(t, blocked) | |
| 237 | + | require.True(t, popupWV.IsDestroyed()) | |
| 238 | + | var record map[string]any | |
| 239 | + | require.NoError(t, json.Unmarshal(output.Bytes(), &record)) | |
| 240 | + | assert.Equal(t, false, record["host_abort_invoked"]) | |
| 241 | + | assert.Equal(t, "cef: native popup arming failed", record["message"]) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |
| 245 | + | parentWV := &WebView{ctx: context.Background(), id: 20} | |
| 246 | + | popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}} | |
| 247 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 248 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 249 | + | Kind: dto.HostDecisionCreateNativePopup, ReasonCode: dto.HostDecisionReasonAuthNativePopup, | |
| 250 | + | }) | |
| 251 | + | popupWV.SetNativePopupHostAbort(func() { popupWV.Destroy() }) | |
| 252 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 253 | + | ||
| 254 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 255 | + | nil, nil, 94, "https://accounts.example.test/oauth", "oauth", | |
| 256 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 257 | + | ) | |
| 258 | + | ||
| 259 | + | require.True(t, blocked) | |
| 260 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 261 | + | require.True(t, popupWV.IsDestroyed()) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestOnBeforePopup_IsPopupFlagCanSelectNativePopup(t *testing.T) { | |
| 265 | + | parentWV := &WebView{ctx: context.Background(), id: 22} | |
| 266 | + | popupWV := &WebView{ctx: context.Background(), id: 30, pendingCreate: &pendingBrowserCreate{}} | |
| 267 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 268 | + | abortCalls := 0 | |
| 269 | + | popupWV.SetNativePopupHostAbort(func() { abortCalls++ }) | |
| 270 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 271 | + | require.Equal(t, dto.PopupFeaturesSpecified, req.PopupFeatures.State) | |
| 272 | + | require.True(t, req.PopupFeatures.IsPopup) | |
| 273 | + | require.True(t, req.PopupFeatures.IsPopupSet) | |
| 274 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 275 | + | return popupWV | |
| 276 | + | }}) | |
| 277 | + | ||
| 278 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 279 | + | nil, nil, 96, "https://example.com/chrome-restricted", "restricted", | |
| 280 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 281 | + | &purecef.PopupFeatures{Ispopup: 1}, nil, nil, nil, nil, nil, | |
| 282 | + | ) | |
| 283 | + | ||
| 284 | + | require.True(t, blocked) | |
| 285 | + | require.Equal(t, 1, abortCalls) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestOnBeforePopup_ReuseNamedPaneBlocksNativePopup(t *testing.T) { | |
| 289 | + | parentWV := &WebView{ctx: context.Background(), id: 23} | |
| 290 | + | popupWV := &WebView{ctx: context.Background(), id: 31, pendingCreate: &pendingBrowserCreate{}} | |
| 291 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 292 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionReuseNamedPane}) | |
| 293 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 294 | + | ||
| 295 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 296 | + | nil, nil, 97, "https://example.com/named", "shared", | |
| 297 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 298 | + | ) | |
| 299 | + | ||
| 300 | + | require.True(t, blocked) | |
| 301 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestOnBeforePopup_HostedDecisionDiscardsUnpreparedNativeCandidate(t *testing.T) { | |
| 305 | + | parentWV := &WebView{ctx: context.Background(), id: 24} | |
| 306 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 307 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 308 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 309 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 310 | + | ||
| 311 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 312 | + | nil, nil, 98, "https://example.com/window", "window", | |
| 313 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 314 | + | ) | |
| 315 | + | ||
| 316 | + | require.True(t, blocked) | |
| 317 | + | require.False(t, popupWV.isNativePopupCandidate()) | |
| 318 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestOnBeforePopup_HostedDecisionPreservesAlreadyPreparedFallbackOpener(t *testing.T) { | |
| 322 | + | parentWV := &WebView{ctx: context.Background(), id: 25} | |
| 323 | + | parentWV.updateURI("https://example.com/opener") | |
| 324 | + | popupWV := &WebView{ctx: context.Background(), id: 33, pendingCreate: &pendingBrowserCreate{}} | |
| 325 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 326 | + | require.True(t, popupWV.preparePopupShellDirectBrowserCreation()) | |
| 327 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 328 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 329 | + | ||
| 330 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 331 | + | nil, nil, 99, "https://example.com/window", "window", | |
| 332 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 333 | + | ) | |
| 334 | + | ||
| 335 | + | require.True(t, blocked) | |
| 336 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 337 | + | require.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |
| 338 | + | require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestOnBeforePopup_DenyCleansUp(t *testing.T) { | |
| 342 | + | parentWV := &WebView{ctx: context.Background(), id: 24} | |
| 343 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 344 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionDeny}) | |
| 345 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 346 | + | ||
| 347 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 348 | + | nil, nil, 98, "https://example.com/denied", "denied", | |
| 349 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 350 | + | ) | |
| 351 | + | ||
| 352 | + | require.True(t, blocked) | |
| 353 | + | require.True(t, popupWV.IsDestroyed()) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestOnBeforePopup_AwaitFeaturesIsImpossibleAndCleansUp(t *testing.T) { | |
| 357 | + | parentWV := &WebView{ctx: context.Background(), id: 21} | |
| 358 | + | popupWV := &WebView{ctx: context.Background(), id: 29} | |
| 359 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionAwaitPopupFeatures}) | |
| 360 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 361 | + | ||
| 362 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 363 | + | nil, nil, 95, "https://example.com/impossible", "impossible", | |
| 364 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 365 | + | ) | |
| 366 | + | ||
| 367 | + | require.True(t, blocked) | |
| 368 | + | require.True(t, popupWV.IsDestroyed()) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |
| 372 | + | delayed := make(chan func(), 1) | |
| 373 | + | parentWV := &WebView{ | |
| 374 | + | ctx: context.Background(), | |
| 375 | + | id: 16, | |
| 376 | + | gtkSyncTimeout: 5 * time.Millisecond, | |
| 377 | + | gtkSyncIsOwner: func() bool { return false }, | |
| 378 | + | gtkSyncDispatch: func(fn func()) { | |
| 379 | + | delayed <- fn | |
| 380 | + | }, | |
| 381 | + | } | |
| 382 | + | var createCalls atomic.Int32 | |
| 383 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 384 | + | OnCreate: func(_ port.PopupRequest) port.WebView { | |
| 385 | + | createCalls.Add(1) | |
| 386 | + | return &WebView{ctx: context.Background(), id: 24} | |
| 387 | + | }, | |
| 388 | + | }) | |
| 389 | + | ||
| 390 | + | h := &handlerSet{wv: parentWV} | |
| 391 | + | blocked := h.OnBeforePopup(nil, nil, 90, "https://example.com/slow-popup", "slow-popup", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 392 | + | ||
| 393 | + | require.True(t, blocked) | |
| 394 | + | require.Zero(t, createCalls.Load()) | |
| 395 | + | ||
| 396 | + | select { | |
| 397 | + | case fn := <-delayed: | |
| 398 | + | fn() | |
| 399 | + | case <-time.After(time.Second): | |
| 400 | + | t.Fatal("timed out waiting for delayed popup dispatch") | |
| 401 | + | } | |
| 402 | + | require.Zero(t, createCalls.Load()) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) { | |
| 406 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 407 | + | popupWV := &WebView{ctx: context.Background(), id: 23} | |
| 408 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 409 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 410 | + | require.Equal(t, "https://example.com/popup", req.TargetURI) | |
| 411 | + | require.Equal(t, "auth-popup", req.FrameName) | |
| 412 | + | require.True(t, req.IsUserGesture) | |
| 413 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 414 | + | return popupWV | |
| 415 | + | }, | |
| 416 | + | }) | |
| 417 | + | ||
| 418 | + | h := &handlerSet{wv: parentWV} | |
| 419 | + | blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 420 | + | ||
| 421 | + | require.True(t, blocked) | |
| 422 | + | require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI()) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) { | |
| 426 | + | parentWV := &WebView{ctx: context.Background(), id: 31} | |
| 427 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 428 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 429 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 430 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 431 | + | require.Equal(t, "https://example.com/login", req.TargetURI) | |
| 432 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 433 | + | return popupWV | |
| 434 | + | }, | |
| 435 | + | }) | |
| 436 | + | ||
| 437 | + | h := &handlerSet{wv: parentWV} | |
| 438 | + | blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 439 | + | ||
| 440 | + | require.True(t, blocked) | |
| 441 | + | require.False(t, popupWV.isNativePopupCandidate()) | |
| 442 | + | require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI()) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) { | |
| 446 | + | parentWV := &WebView{ctx: context.Background(), id: 41} | |
| 447 | + | popupWV := &WebView{ctx: context.Background(), id: 42} | |
| 448 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane}) | |
| 449 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 450 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 451 | + | popupWV.PreparePaneHostedBrowsingContext() | |
| 452 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 453 | + | return popupWV | |
| 454 | + | }, | |
| 455 | + | }) | |
| 456 | + | ||
| 457 | + | h := &handlerSet{wv: parentWV} | |
| 458 | + | blocked := h.OnBeforePopup(nil, nil, 81, "https://example.com/pane", "_blank", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 459 | + | ||
| 460 | + | require.True(t, blocked) | |
| 461 | + | require.Equal(t, "https://example.com/pane", popupWV.pendingNavigationURI()) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) { | |
| 465 | + | parentWV := &WebView{ctx: context.Background(), id: 51} | |
| 466 | + | popupWV := &WebView{ctx: context.Background(), id: 52} | |
| 467 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 468 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 469 | + | aborted := false | |
| 470 | + | popupWV.SetNativePopupHostAbort(func() { aborted = true }) | |
| 471 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 472 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 473 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 474 | + | return popupWV | |
| 475 | + | }, | |
| 476 | + | }) | |
| 477 | + | ||
| 478 | + | h := &handlerSet{wv: parentWV} | |
| 479 | + | blocked := h.OnBeforePopup(nil, nil, 82, "https://accounts.google.com/o/oauth2/v2/auth", "oauth", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 480 | + | ||
| 481 | + | require.True(t, blocked) | |
| 482 | + | require.True(t, aborted) | |
| 483 | + | } | |
M internal/infrastructure/webkit/webview.go +113 -33
| @@ -32,6 +32,7 @@ var _ port.WebView = (*WebView)(nil) | |||
| 32 | 32 | var _ port.DevToolsOpener = (*WebView)(nil) | |
| 33 | 33 | var _ port.Printer = (*WebView)(nil) | |
| 34 | 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 35 | + | var _ port.PopupFeatureResolver = (*WebView)(nil) | |
| 35 | 36 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 36 | 37 | ||
| 37 | 38 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| @@ -50,10 +51,11 @@ const ( | |||
| 50 | 51 | ||
| 51 | 52 | // PopupRequest contains information about a popup window request from the create signal. | |
| 52 | 53 | type PopupRequest struct { | |
| 53 | - | TargetURI string | |
| 54 | - | FrameName string // e.g., "_blank", custom name, or empty | |
| 55 | - | IsUserGesture bool | |
| 56 | - | ParentID WebViewID | |
| 54 | + | TargetURI string | |
| 55 | + | FrameName string // e.g., "_blank", custom name, or empty | |
| 56 | + | NavigationType webkit.NavigationType | |
| 57 | + | IsUserGesture bool | |
| 58 | + | ParentID WebViewID | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | // webViewRegistry tracks all active WebViews. | |
| @@ -154,6 +156,7 @@ type WebView struct { | |||
| 154 | 156 | OnProgressChanged func(float64) | |
| 155 | 157 | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | |
| 156 | 158 | OnClose func() | |
| 159 | + | popupLifecycleClose func() | |
| 157 | 160 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | |
| 158 | 161 | OnReadyToShow func() // Called when popup is ready to display | |
| 159 | 162 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | |
| @@ -514,14 +517,50 @@ func (wv *WebView) connectWebProcessResponsiveSignal() { | |||
| 514 | 517 | ||
| 515 | 518 | func (wv *WebView) connectCloseSignal() { | |
| 516 | 519 | closeCb := func(_ webkit.WebView) { | |
| 517 | - | if wv.OnClose != nil { | |
| 518 | - | wv.OnClose() | |
| 519 | - | } | |
| 520 | + | wv.runCloseCallbacks() | |
| 520 | 521 | } | |
| 521 | 522 | sigID := wv.inner.ConnectClose(&closeCb) | |
| 522 | 523 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 523 | 524 | } | |
| 524 | 525 | ||
| 526 | + | func popupFeaturesFromWindowProperties( | |
| 527 | + | x, y, width, height int, | |
| 528 | + | toolbarVisible, locationbarVisible, resizable bool, | |
| 529 | + | ) dto.PopupFeatures { | |
| 530 | + | chromeRestricted := !toolbarVisible || !locationbarVisible || !resizable | |
| 531 | + | features := dto.PopupFeatures{ | |
| 532 | + | X: x, Y: y, Width: width, Height: height, | |
| 533 | + | XSet: x != 0, YSet: y != 0, WidthSet: width > 0, HeightSet: height > 0, | |
| 534 | + | ToolbarVisible: toolbarVisible, LocationbarVisible: locationbarVisible, | |
| 535 | + | ToolbarVisibilitySet: true, LocationbarVisibilitySet: true, | |
| 536 | + | Resizable: resizable, ResizableSet: true, | |
| 537 | + | IsPopup: chromeRestricted, IsPopupSet: true, | |
| 538 | + | State: dto.PopupFeaturesNone, | |
| 539 | + | } | |
| 540 | + | if width > 0 || height > 0 || chromeRestricted { | |
| 541 | + | features.State = dto.PopupFeaturesSpecified | |
| 542 | + | } | |
| 543 | + | return features | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // ResolvePopupFeatures reads WebKit's late window metadata. WebKit documents | |
| 547 | + | // these properties as reliable when ready-to-show is emitted. | |
| 548 | + | func (wv *WebView) ResolvePopupFeatures() dto.PopupFeatures { | |
| 549 | + | if wv == nil || wv.inner == nil || wv.destroyed.Load() { | |
| 550 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |
| 551 | + | } | |
| 552 | + | props := wv.inner.GetWindowProperties() | |
| 553 | + | if props == nil { | |
| 554 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |
| 555 | + | } | |
| 556 | + | geometry := &gdk.Rectangle{} | |
| 557 | + | props.GetGeometry(geometry) | |
| 558 | + | return popupFeaturesFromWindowProperties( | |
| 559 | + | geometry.X, geometry.Y, geometry.Width, geometry.Height, | |
| 560 | + | props.GetToolbarVisible(), props.GetLocationbarVisible(), props.GetResizable(), | |
| 561 | + | ) | |
| 562 | + | } | |
| 563 | + | ||
| 525 | 564 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 526 | 565 | wv.mu.Lock() | |
| 527 | 566 | wv.browsingContextDecision = decision | |
| @@ -568,10 +607,11 @@ func (wv *WebView) connectCreateSignal() { | |||
| 568 | 607 | } | |
| 569 | 608 | ||
| 570 | 609 | popupReq := PopupRequest{ | |
| 571 | - | TargetURI: targetURI, | |
| 572 | - | FrameName: navAction.GetFrameName(), | |
| 573 | - | IsUserGesture: navAction.IsUserGesture(), | |
| 574 | - | ParentID: wv.id, | |
| 610 | + | TargetURI: targetURI, | |
| 611 | + | FrameName: navAction.GetFrameName(), | |
| 612 | + | NavigationType: navAction.GetNavigationType(), | |
| 613 | + | IsUserGesture: navAction.IsUserGesture(), | |
| 614 | + | ParentID: wv.id, | |
| 575 | 615 | } | |
| 576 | 616 | ||
| 577 | 617 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | |
| @@ -594,9 +634,7 @@ func (wv *WebView) connectCreateSignal() { | |||
| 594 | 634 | ||
| 595 | 635 | func (wv *WebView) connectReadyToShowSignal() { | |
| 596 | 636 | readyToShowCb := func(_ webkit.WebView) { | |
| 597 | - | if wv.OnReadyToShow != nil { | |
| 598 | - | wv.OnReadyToShow() | |
| 599 | - | } | |
| 637 | + | wv.fireReadyToShow() | |
| 600 | 638 | } | |
| 601 | 639 | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) | |
| 602 | 640 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| @@ -1624,27 +1662,55 @@ func (*WebView) PrimePopupNavigation(string) {} | |||
| 1624 | 1662 | // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display. | |
| 1625 | 1663 | // It implements port.PopupLifecycleCapable. | |
| 1626 | 1664 | func (wv *WebView) SetOnReadyToShow(fn func()) { | |
| 1665 | + | if wv == nil { | |
| 1666 | + | return | |
| 1667 | + | } | |
| 1668 | + | wv.mu.Lock() | |
| 1627 | 1669 | wv.OnReadyToShow = fn | |
| 1670 | + | wv.mu.Unlock() | |
| 1628 | 1671 | } | |
| 1629 | 1672 | ||
| 1630 | - | // SetOnClose composes fn with any existing OnClose handler so multiple callers | |
| 1631 | - | // can each register a close callback without overwriting one another. | |
| 1632 | - | // It implements port.PopupLifecycleCapable. | |
| 1673 | + | // SetOnClose replaces the coordinator-owned popup lifecycle callback without | |
| 1674 | + | // disturbing WebView callbacks or additive OAuth close callbacks. | |
| 1633 | 1675 | func (wv *WebView) SetOnClose(fn func()) { | |
| 1634 | - | existing := wv.OnClose | |
| 1635 | - | if existing == nil { | |
| 1636 | - | wv.OnClose = fn | |
| 1676 | + | if wv == nil { | |
| 1637 | 1677 | return | |
| 1638 | 1678 | } | |
| 1639 | - | if fn == nil { | |
| 1679 | + | wv.mu.Lock() | |
| 1680 | + | wv.popupLifecycleClose = fn | |
| 1681 | + | wv.mu.Unlock() | |
| 1682 | + | } | |
| 1683 | + | ||
| 1684 | + | func (wv *WebView) fireReadyToShow() { | |
| 1685 | + | if wv == nil { | |
| 1640 | 1686 | return | |
| 1641 | 1687 | } | |
| 1642 | - | wv.OnClose = func() { | |
| 1643 | - | existing() | |
| 1688 | + | wv.mu.RLock() | |
| 1689 | + | fn := wv.OnReadyToShow | |
| 1690 | + | wv.mu.RUnlock() | |
| 1691 | + | if fn != nil { | |
| 1644 | 1692 | fn() | |
| 1645 | 1693 | } | |
| 1646 | 1694 | } | |
| 1647 | 1695 | ||
| 1696 | + | func (wv *WebView) runCloseCallbacks() { | |
| 1697 | + | if wv == nil { | |
| 1698 | + | return | |
| 1699 | + | } | |
| 1700 | + | wv.mu.Lock() | |
| 1701 | + | base := wv.OnClose | |
| 1702 | + | lifecycle := wv.popupLifecycleClose | |
| 1703 | + | wv.OnClose = nil | |
| 1704 | + | wv.popupLifecycleClose = nil | |
| 1705 | + | wv.mu.Unlock() | |
| 1706 | + | if base != nil { | |
| 1707 | + | base() | |
| 1708 | + | } | |
| 1709 | + | if lifecycle != nil { | |
| 1710 | + | lifecycle() | |
| 1711 | + | } | |
| 1712 | + | } | |
| 1713 | + | ||
| 1648 | 1714 | // State returns the current WebView state as a snapshot. | |
| 1649 | 1715 | func (wv *WebView) State() port.WebViewState { | |
| 1650 | 1716 | return port.WebViewState{ | |
| @@ -1658,6 +1724,26 @@ func (wv *WebView) State() port.WebViewState { | |||
| 1658 | 1724 | } | |
| 1659 | 1725 | } | |
| 1660 | 1726 | ||
| 1727 | + | func mapPopupRequest(req PopupRequest) port.PopupRequest { | |
| 1728 | + | mapped := port.PopupRequest{ | |
| 1729 | + | Engine: dto.BrowserEngineWebKit, | |
| 1730 | + | TargetURI: req.TargetURI, | |
| 1731 | + | FrameName: req.FrameName, | |
| 1732 | + | IsUserGesture: req.IsUserGesture, | |
| 1733 | + | ParentViewID: req.ParentID, | |
| 1734 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1735 | + | } | |
| 1736 | + | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | |
| 1737 | + | if req.NavigationType == webkit.NavigationTypeLinkClickedValue { | |
| 1738 | + | mapped.TargetDisposition = dto.WindowDispositionNewTab | |
| 1739 | + | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |
| 1740 | + | } else { | |
| 1741 | + | mapped.TargetDisposition = dto.WindowDispositionNewPopup | |
| 1742 | + | } | |
| 1743 | + | } | |
| 1744 | + | return mapped | |
| 1745 | + | } | |
| 1746 | + | ||
| 1661 | 1747 | // SetCallbacks registers callback handlers for WebView events. | |
| 1662 | 1748 | // Pass nil to clear all callbacks. | |
| 1663 | 1749 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |
| @@ -1696,13 +1782,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1696 | 1782 | wv.OnClose = callbacks.OnClose | |
| 1697 | 1783 | if callbacks.OnCreate != nil { | |
| 1698 | 1784 | wv.OnCreate = func(req PopupRequest) *WebView { | |
| 1699 | - | portReq := port.PopupRequest{ | |
| 1700 | - | TargetURI: req.TargetURI, | |
| 1701 | - | FrameName: req.FrameName, | |
| 1702 | - | IsUserGesture: req.IsUserGesture, | |
| 1703 | - | ParentViewID: req.ParentID, | |
| 1704 | - | } | |
| 1705 | - | result := callbacks.OnCreate(portReq) | |
| 1785 | + | result := callbacks.OnCreate(mapPopupRequest(req)) | |
| 1706 | 1786 | if result == nil { | |
| 1707 | 1787 | return nil | |
| 1708 | 1788 | } | |
| @@ -1793,9 +1873,7 @@ func (wv *WebView) Close() { | |||
| 1793 | 1873 | if wv.destroyed.Load() { | |
| 1794 | 1874 | return | |
| 1795 | 1875 | } | |
| 1796 | - | if wv.OnClose != nil { | |
| 1797 | - | wv.OnClose() | |
| 1798 | - | } | |
| 1876 | + | wv.runCloseCallbacks() | |
| 1799 | 1877 | } | |
| 1800 | 1878 | ||
| 1801 | 1879 | // AddCloseCallback implements port.OAuthCallbackCapable. | |
| @@ -1931,6 +2009,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) { | |||
| 1931 | 2009 | wv.browsingContextDecision = dto.HostDecision{} | |
| 1932 | 2010 | wv.hasBrowsingContextDecision = false | |
| 1933 | 2011 | wv.nativePopupHostAbort = nil | |
| 2012 | + | wv.popupLifecycleClose = nil | |
| 1934 | 2013 | wv.mu.Unlock() | |
| 1935 | 2014 | ||
| 1936 | 2015 | // 4. Unparent from GTK hierarchy (must happen before process termination) | |
| @@ -2001,6 +2080,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||
| 2001 | 2080 | wv.browsingContextDecision = dto.HostDecision{} | |
| 2002 | 2081 | wv.hasBrowsingContextDecision = false | |
| 2003 | 2082 | wv.nativePopupHostAbort = nil | |
| 2083 | + | wv.popupLifecycleClose = nil | |
| 2004 | 2084 | wv.lastProgressUpdate.Store(0) | |
| 2005 | 2085 | wv.mu.Unlock() | |
| 2006 | 2086 | ||
A internal/infrastructure/webkit/webview_popup_lifecycle_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPopupCloseCallbacksRunExactlyOnce(t *testing.T) { | |
| 10 | + | wv := &WebView{} | |
| 11 | + | baseCalls := 0 | |
| 12 | + | lifecycleCalls := 0 | |
| 13 | + | wv.AddCloseCallback(func() { baseCalls++ }) | |
| 14 | + | wv.SetOnClose(func() { lifecycleCalls++ }) | |
| 15 | + | ||
| 16 | + | wv.runCloseCallbacks() | |
| 17 | + | wv.runCloseCallbacks() | |
| 18 | + | ||
| 19 | + | assert.Equal(t, 1, baseCalls) | |
| 20 | + | assert.Equal(t, 1, lifecycleCalls) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) { | |
| 24 | + | wv := &WebView{} | |
| 25 | + | readyCalls := 0 | |
| 26 | + | lifecycleCloseCalls := 0 | |
| 27 | + | oauthCloseCalls := 0 | |
| 28 | + | wv.SetOnReadyToShow(func() { readyCalls++ }) | |
| 29 | + | wv.SetOnClose(func() { lifecycleCloseCalls++ }) | |
| 30 | + | wv.AddCloseCallback(func() { oauthCloseCalls++ }) | |
| 31 | + | ||
| 32 | + | wv.SetOnReadyToShow(nil) | |
| 33 | + | wv.SetOnClose(nil) | |
| 34 | + | wv.fireReadyToShow() | |
| 35 | + | wv.runCloseCallbacks() | |
| 36 | + | ||
| 37 | + | assert.Zero(t, readyCalls) | |
| 38 | + | assert.Zero(t, lifecycleCloseCalls) | |
| 39 | + | assert.Equal(t, 1, oauthCloseCalls) | |
| 40 | + | } | |
A internal/infrastructure/webkit/webview_popup_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | purewebkit "github.com/bnema/puregotk/v4/webkit" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestResolvePopupFeatures(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | ||
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | x, y, width, height int | |
| 19 | + | toolbar, location bool | |
| 20 | + | resizable bool | |
| 21 | + | wantState dto.PopupFeatureState | |
| 22 | + | wantPopup bool | |
| 23 | + | }{ | |
| 24 | + | {name: "default properties are featureless", toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesNone}, | |
| 25 | + | {name: "geometry is popup like", width: 640, height: 480, toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 26 | + | {name: "hidden toolbar is popup like", toolbar: false, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 27 | + | {name: "hidden location bar is popup like", toolbar: true, location: false, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 28 | + | {name: "disabled resizing is popup like", toolbar: true, location: true, resizable: false, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | t.Parallel() | |
| 34 | + | got := popupFeaturesFromWindowProperties(tt.x, tt.y, tt.width, tt.height, tt.toolbar, tt.location, tt.resizable) | |
| 35 | + | assert.Equal(t, tt.wantState, got.State) | |
| 36 | + | assert.Equal(t, tt.width > 0, got.WidthSet) | |
| 37 | + | assert.Equal(t, tt.height > 0, got.HeightSet) | |
| 38 | + | assert.True(t, got.ToolbarVisibilitySet) | |
| 39 | + | assert.True(t, got.LocationbarVisibilitySet) | |
| 40 | + | assert.True(t, got.ResizableSet) | |
| 41 | + | assert.Equal(t, tt.wantPopup && (!tt.toolbar || !tt.location || !tt.resizable), got.IsPopup) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestMapPopupRequestClassifiesLinkBlankAsFeatureless(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | ||
| 49 | + | blank := mapPopupRequest(PopupRequest{ | |
| 50 | + | TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7, | |
| 51 | + | NavigationType: purewebkit.NavigationTypeLinkClickedValue, | |
| 52 | + | }) | |
| 53 | + | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | |
| 54 | + | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | |
| 55 | + | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) { | |
| 59 | + | t.Parallel() | |
| 60 | + | ||
| 61 | + | script := mapPopupRequest(PopupRequest{ | |
| 62 | + | TargetURI: "https://example.com/popup", FrameName: "_blank", ParentID: 7, | |
| 63 | + | NavigationType: purewebkit.NavigationTypeOtherValue, | |
| 64 | + | }) | |
| 65 | + | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | |
| 66 | + | assert.Equal(t, dto.WindowDispositionNewPopup, script.TargetDisposition) | |
| 67 | + | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestPopupCreateCallbackPreservesNilNamedReuseResult(t *testing.T) { | |
| 71 | + | wv := &WebView{} | |
| 72 | + | wv.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 73 | + | require.Equal(t, dto.BrowserEngineWebKit, req.Engine) | |
| 74 | + | require.Equal(t, "shared", req.FrameName) | |
| 75 | + | return nil // Existing named context was navigated; block WebKit creation. | |
| 76 | + | }}) | |
| 77 | + | ||
| 78 | + | result := wv.OnCreate(PopupRequest{ | |
| 79 | + | TargetURI: "https://example.com/reused", FrameName: "shared", ParentID: 7, | |
| 80 | + | NavigationType: purewebkit.NavigationTypeOtherValue, | |
| 81 | + | }) | |
| 82 | + | ||
| 83 | + | require.Nil(t, result) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestResolvePopupFeaturesNilWebViewIsUnknown(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | var wv *WebView | |
| 89 | + | assert.Equal(t, dto.PopupFeaturesUnknown, wv.ResolvePopupFeatures().State) | |
| 90 | + | } | |
M internal/infrastructure/webkit/webview_reuse_state_test.go +1 -1
| @@ -11,7 +11,7 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) { | |||
| 11 | 11 | wv := &WebView{} | |
| 12 | 12 | abortCalled := false | |
| 13 | 13 | ||
| 14 | - | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | |
| 14 | + | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 15 | 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | |
| 16 | 16 | ||
| 17 | 17 | wv.ResetForPoolReuse() | |
M internal/ui/app.go +57 -33
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | "unsafe" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 16 | 17 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | 18 | "github.com/bnema/dumber/internal/application/usecase" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -614,6 +615,20 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity. | |||
| 614 | 615 | return a.lastFocusedBrowserWindow() | |
| 615 | 616 | } | |
| 616 | 617 | ||
| 618 | + | func (a *App) openPopupNativeWindow(ctx context.Context, popupInput content.NativePopupInput) error { | |
| 619 | + | if err := a.openNativePopupWindow(ctx, popupInput); err != nil { | |
| 620 | + | return err | |
| 621 | + | } | |
| 622 | + | // Deferred WebKit classification runs from ready-to-show. The native host | |
| 623 | + | // is attached after that signal, so it must be revealed immediately rather | |
| 624 | + | // than waiting for an already-delivered lifecycle callback. | |
| 625 | + | if popupInput.Request.Engine == dto.BrowserEngineWebKit && | |
| 626 | + | popupInput.Request.PopupFeatures.State == dto.PopupFeaturesSpecified { | |
| 627 | + | a.showNativePopupWindow(popupInput.PopupWebView.ID()) | |
| 628 | + | } | |
| 629 | + | return nil | |
| 630 | + | } | |
| 631 | + | ||
| 617 | 632 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | |
| 618 | 633 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | |
| 619 | 634 | if bw == nil { | |
| @@ -2941,13 +2956,16 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 2941 | 2956 | for _, tabID := range tabIDs { | |
| 2942 | 2957 | a.releaseFloatingSessionsForTab(ctx, tabID) | |
| 2943 | 2958 | } | |
| 2959 | + | if a.contentCoord != nil { | |
| 2960 | + | a.contentCoord.ShutdownPopups() | |
| 2961 | + | } | |
| 2944 | 2962 | if len(a.nativePopupWindows) > 0 { | |
| 2945 | 2963 | popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows)) | |
| 2946 | 2964 | for popupID := range a.nativePopupWindows { | |
| 2947 | 2965 | popupIDs = append(popupIDs, popupID) | |
| 2948 | 2966 | } | |
| 2949 | 2967 | for _, popupID := range popupIDs { | |
| 2950 | - | a.releaseNativePopupWindow(popupID, false, true) | |
| 2968 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) | |
| 2951 | 2969 | } | |
| 2952 | 2970 | } | |
| 2953 | 2971 | if a.engine != nil { | |
| @@ -3077,9 +3095,7 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 3077 | 3095 | } | |
| 3078 | 3096 | }) | |
| 3079 | 3097 | // Wire popup tab WebView attachment | |
| 3080 | - | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) { | |
| 3081 | - | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| 3082 | - | }) | |
| 3098 | + | a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab) | |
| 3083 | 3099 | ||
| 3084 | 3100 | for _, bw := range a.browserWindows { | |
| 3085 | 3101 | a.wireBrowserWindowTabBar(ctx, bw) | |
| @@ -3157,13 +3173,15 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3157 | 3173 | &runtimeCfg.Workspace.BrowsingContexts, | |
| 3158 | 3174 | a.generateID, | |
| 3159 | 3175 | ) | |
| 3160 | - | a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) { | |
| 3161 | - | bw := a.browserWindowForAnyPane(paneID) | |
| 3162 | - | if bw == nil { | |
| 3163 | - | return "", false | |
| 3176 | + | a.contentCoord.SetPopupSourceHostResolver(func(paneID entity.PaneID) dto.SourceHostKind { | |
| 3177 | + | if a.floatingSessionByPaneID(paneID) != nil { | |
| 3178 | + | return dto.SourceHostFloating | |
| 3164 | 3179 | } | |
| 3165 | - | return bw.id, true | |
| 3180 | + | return dto.SourceHostWorkspace | |
| 3166 | 3181 | }) | |
| 3182 | + | a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane) | |
| 3183 | + | a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow) | |
| 3184 | + | a.contentCoord.SetOnStagePopup(a.stagePopup) | |
| 3167 | 3185 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | |
| 3168 | 3186 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | |
| 3169 | 3187 | a.activateBrowserWindow(bw) | |
| @@ -3176,7 +3194,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3176 | 3194 | } | |
| 3177 | 3195 | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 3178 | 3196 | }) | |
| 3179 | - | a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow) | |
| 3197 | + | a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow) | |
| 3180 | 3198 | // Wire tabbed popup behavior to create new tabs in the originating window. | |
| 3181 | 3199 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | |
| 3182 | 3200 | ||
| @@ -3930,55 +3948,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||
| 3930 | 3948 | ||
| 3931 | 3949 | // attachPopupToTab attaches a popup WebView to a newly created tab. | |
| 3932 | 3950 | // This is called when a popup uses tabbed behavior. | |
| 3933 | - | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) { | |
| 3951 | + | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error { | |
| 3934 | 3952 | log := logging.FromContext(ctx) | |
| 3935 | - | cleanupUnattachedPopup := func() { | |
| 3936 | - | if wv != nil { | |
| 3937 | - | wv.Destroy() | |
| 3938 | - | } | |
| 3939 | - | } | |
| 3940 | - | ||
| 3941 | 3953 | wsView := a.workspaceViews[tabID] | |
| 3942 | 3954 | if wsView == nil { | |
| 3943 | - | cleanupUnattachedPopup() | |
| 3944 | 3955 | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | |
| 3945 | - | return | |
| 3956 | + | return fmt.Errorf("workspace view not found for popup tab %q", tabID) | |
| 3946 | 3957 | } | |
| 3947 | 3958 | if pane == nil { | |
| 3948 | - | cleanupUnattachedPopup() | |
| 3949 | 3959 | log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil") | |
| 3950 | - | return | |
| 3960 | + | return fmt.Errorf("popup pane is nil") | |
| 3951 | 3961 | } | |
| 3952 | 3962 | if wsView.GetPaneView(pane.ID) == nil { | |
| 3953 | - | cleanupUnattachedPopup() | |
| 3954 | 3963 | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 3955 | - | return | |
| 3964 | + | return fmt.Errorf("pane view not found for popup %q", pane.ID) | |
| 3956 | 3965 | } | |
| 3957 | 3966 | if a.contentCoord == nil { | |
| 3958 | - | cleanupUnattachedPopup() | |
| 3959 | 3967 | log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup") | |
| 3960 | - | return | |
| 3968 | + | return fmt.Errorf("content coordinator not configured for popup") | |
| 3961 | 3969 | } | |
| 3962 | 3970 | ||
| 3963 | - | // Register WebView with content coordinator. | |
| 3964 | - | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | |
| 3965 | - | ||
| 3966 | - | // Wrap and attach widget. | |
| 3971 | + | // Wrap and attach before registration so a failed attachment leaves the | |
| 3972 | + | // caller-owned WebView untouched and unregistered. | |
| 3967 | 3973 | widget := a.contentCoord.WrapWidget(ctx, wv) | |
| 3968 | 3974 | if widget == nil { | |
| 3969 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |
| 3970 | 3975 | log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview") | |
| 3971 | - | return | |
| 3976 | + | return fmt.Errorf("wrap popup webview") | |
| 3972 | 3977 | } | |
| 3973 | 3978 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | |
| 3974 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |
| 3975 | 3979 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 3976 | - | return | |
| 3980 | + | return fmt.Errorf("set popup webview widget: %w", err) | |
| 3977 | 3981 | } | |
| 3982 | + | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | |
| 3978 | 3983 | log.Debug(). | |
| 3979 | 3984 | Str("tab_id", string(tabID)). | |
| 3980 | 3985 | Str("pane_id", string(pane.ID)). | |
| 3981 | 3986 | Msg("popup webview attached to tab") | |
| 3987 | + | return nil | |
| 3982 | 3988 | } | |
| 3983 | 3989 | ||
| 3984 | 3990 | // switchWorkspaceView swaps the displayed workspace view for a tab. | |
| @@ -4584,6 +4590,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe | |||
| 4584 | 4590 | return nil | |
| 4585 | 4591 | } | |
| 4586 | 4592 | ||
| 4593 | + | // popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A | |
| 4594 | + | // floating pane is mapped through its session's tab, never by parsing PaneID. | |
| 4595 | + | func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) { | |
| 4596 | + | for key, session := range a.floatingSessions { | |
| 4597 | + | if session == nil || session.paneID != paneID { | |
| 4598 | + | continue | |
| 4599 | + | } | |
| 4600 | + | if bw := a.browserWindowForTab(key.tabID); bw != nil { | |
| 4601 | + | return bw.id, true | |
| 4602 | + | } | |
| 4603 | + | return "", false | |
| 4604 | + | } | |
| 4605 | + | if bw := a.browserWindowForAnyPane(paneID); bw != nil { | |
| 4606 | + | return bw.id, true | |
| 4607 | + | } | |
| 4608 | + | return "", false | |
| 4609 | + | } | |
| 4610 | + | ||
| 4587 | 4611 | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |
| 4588 | 4612 | if session == nil || session.overlay == nil || session.resizeWatcherActive { | |
| 4589 | 4613 | return | |
M internal/ui/app_browser_launch_test.go +33 -14
| @@ -1313,6 +1313,24 @@ func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) { | |||
| 1313 | 1313 | } | |
| 1314 | 1314 | } | |
| 1315 | 1315 | ||
| 1316 | + | func TestApp_PopupOwnerWindowIDForFloatingPaneUsesSourceTabWindow(t *testing.T) { | |
| 1317 | + | tabID := entity.TabID("source-tab") | |
| 1318 | + | owner := &browserWindow{id: "owner-window", tabs: entity.NewTabList()} | |
| 1319 | + | paneID := entity.PaneID("floating-pane") | |
| 1320 | + | app := &App{ | |
| 1321 | + | browserWindows: map[string]*browserWindow{owner.id: owner}, | |
| 1322 | + | windowForTab: map[entity.TabID]*browserWindow{tabID: owner}, | |
| 1323 | + | floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{ | |
| 1324 | + | {tabID: tabID, sessionID: "profile"}: {paneID: paneID}, | |
| 1325 | + | }, | |
| 1326 | + | } | |
| 1327 | + | ||
| 1328 | + | windowID, ok := app.popupOwnerWindowIDForPane(paneID) | |
| 1329 | + | ||
| 1330 | + | require.True(t, ok) | |
| 1331 | + | require.Equal(t, owner.id, windowID) | |
| 1332 | + | } | |
| 1333 | + | ||
| 1316 | 1334 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | |
| 1317 | 1335 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 1318 | 1336 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| @@ -2279,6 +2297,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) { | |||
| 2279 | 2297 | mainWindow: first.mainWindow, | |
| 2280 | 2298 | } | |
| 2281 | 2299 | app.initTabCoordinator(ctx) | |
| 2300 | + | app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 2282 | 2301 | ||
| 2283 | 2302 | err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{ | |
| 2284 | 2303 | ParentPaneID: secondTab.Workspace.ActivePaneID, | |
| @@ -2538,28 +2557,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f | |||
| 2538 | 2557 | func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 } | |
| 2539 | 2558 | func (f *recordingWebView) Destroy() { f.destroyCalls++ } | |
| 2540 | 2559 | ||
| 2541 | - | func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) { | |
| 2560 | + | func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) { | |
| 2542 | 2561 | ctx := context.Background() | |
| 2543 | 2562 | popupWV := &recordingWebView{id: 1} | |
| 2544 | 2563 | app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}} | |
| 2545 | 2564 | ||
| 2546 | - | app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV) | |
| 2565 | + | err := app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV) | |
| 2547 | 2566 | ||
| 2548 | - | if popupWV.destroyCalls != 1 { | |
| 2549 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | |
| 2567 | + | if err == nil || popupWV.destroyCalls != 0 { | |
| 2568 | + | t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls) | |
| 2550 | 2569 | } | |
| 2551 | 2570 | } | |
| 2552 | 2571 | ||
| 2553 | - | func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) { | |
| 2572 | + | func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) { | |
| 2554 | 2573 | ctx := context.Background() | |
| 2555 | 2574 | popupWV := &recordingWebView{id: 1} | |
| 2556 | 2575 | tabID := entity.TabID("tab-1") | |
| 2557 | 2576 | app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}} | |
| 2558 | 2577 | ||
| 2559 | - | app.attachPopupToTab(ctx, tabID, nil, popupWV) | |
| 2578 | + | err := app.attachPopupToTab(ctx, tabID, nil, popupWV) | |
| 2560 | 2579 | ||
| 2561 | - | if popupWV.destroyCalls != 1 { | |
| 2562 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | |
| 2580 | + | if err == nil || popupWV.destroyCalls != 0 { | |
| 2581 | + | t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls) | |
| 2563 | 2582 | } | |
| 2564 | 2583 | } | |
| 2565 | 2584 | ||
| @@ -2576,13 +2595,13 @@ func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) | |||
| 2576 | 2595 | } | |
| 2577 | 2596 | ||
| 2578 | 2597 | popupWV := &recordingWebView{id: 1} | |
| 2579 | - | app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2598 | + | err := app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2580 | 2599 | ||
| 2581 | 2600 | if got := contentCoord.GetWebView(pane.ID); got != nil { | |
| 2582 | 2601 | t.Fatalf("popup webview was registered for missing pane view: %v", got) | |
| 2583 | 2602 | } | |
| 2584 | - | if popupWV.destroyCalls != 1 { | |
| 2585 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | |
| 2603 | + | if err == nil || popupWV.destroyCalls != 0 { | |
| 2604 | + | t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls) | |
| 2586 | 2605 | } | |
| 2587 | 2606 | } | |
| 2588 | 2607 | ||
| @@ -2617,13 +2636,13 @@ func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) { | |||
| 2617 | 2636 | } | |
| 2618 | 2637 | popupWV := &recordingWebView{id: 1} | |
| 2619 | 2638 | ||
| 2620 | - | app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2639 | + | err := app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2621 | 2640 | ||
| 2622 | 2641 | if got := contentCoord.GetWebView(pane.ID); got != nil { | |
| 2623 | 2642 | t.Fatalf("popup webview registration remained after wrap failure: %v", got) | |
| 2624 | 2643 | } | |
| 2625 | - | if popupWV.destroyCalls != 1 { | |
| 2626 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | |
| 2644 | + | if err == nil || popupWV.destroyCalls != 0 { | |
| 2645 | + | t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls) | |
| 2627 | 2646 | } | |
| 2628 | 2647 | } | |
| 2629 | 2648 | ||
A internal/ui/browser_popup_window.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var showPopupBrowserWindow = func(bw *browserWindow) { | |
| 14 | + | if bw != nil && bw.mainWindow != nil { | |
| 15 | + | bw.mainWindow.Show() | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // openPopupBrowserWindow adopts the exact related WebView created for a | |
| 20 | + | // browsing-context request into a complete Dumber browser window. | |
| 21 | + | func (a *App) openPopupBrowserWindow(ctx context.Context, input content.BrowserWindowInput) (content.BrowserWindowResult, error) { | |
| 22 | + | if input.PopupPane == nil || input.PopupWebView == nil { | |
| 23 | + | return content.BrowserWindowResult{}, fmt.Errorf("popup browser window requires pane and webview") | |
| 24 | + | } | |
| 25 | + | bw, err := a.createEmptyBrowserWindow(ctx) | |
| 26 | + | if err != nil { | |
| 27 | + | return content.BrowserWindowResult{}, err | |
| 28 | + | } | |
| 29 | + | tab, err := a.tabCoord.CreateWithPane( | |
| 30 | + | ctx, | |
| 31 | + | a.ensureTabTargetForBrowserWindow(bw), | |
| 32 | + | input.PopupPane, | |
| 33 | + | input.PopupWebView, | |
| 34 | + | input.TargetURI, | |
| 35 | + | ) | |
| 36 | + | if err != nil { | |
| 37 | + | a.cleanupEjectTargetWindow(bw) | |
| 38 | + | return content.BrowserWindowResult{}, err | |
| 39 | + | } | |
| 40 | + | a.setBrowserWindowForTab(tab.ID, bw) | |
| 41 | + | a.activateBrowserWindow(bw) | |
| 42 | + | ||
| 43 | + | var showOnce sync.Once | |
| 44 | + | show := func() { | |
| 45 | + | showOnce.Do(func() { showPopupBrowserWindow(bw) }) | |
| 46 | + | } | |
| 47 | + | if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok { | |
| 48 | + | lifecycle.SetOnClose(func() { a.closeAdoptedPopupTab(context.Background(), bw, tab.ID) }) | |
| 49 | + | if !input.Ready { | |
| 50 | + | lifecycle.SetOnReadyToShow(show) | |
| 51 | + | return content.BrowserWindowResult{WindowID: bw.id}, nil | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | show() | |
| 55 | + | return content.BrowserWindowResult{WindowID: bw.id}, nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (a *App) closeAdoptedPopupTab(ctx context.Context, bw *browserWindow, tabID entity.TabID) { | |
| 59 | + | if a == nil || bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | bw.tabs.SetActive(tabID) | |
| 63 | + | _ = a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw)) | |
| 64 | + | } | |
A internal/ui/browser_popup_window_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 16 | + | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 17 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func newPopupAdoptionApp(t *testing.T, attach func(context.Context, entity.TabID, *entity.Pane, port.WebView) error) (*App, *browserWindow) { | |
| 21 | + | t.Helper() | |
| 22 | + | bw := &browserWindow{id: "detached-window", tabs: entity.NewTabList()} | |
| 23 | + | app := &App{ | |
| 24 | + | tabs: entity.NewTabList(), tabsUC: usecase.NewManageTabsUseCase(func() string { return "adopted-tab" }, nil), | |
| 25 | + | browserWindows: map[string]*browserWindow{}, windowForTab: map[entity.TabID]*browserWindow{}, | |
| 26 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 27 | + | browserWindowFactory: func(context.Context, string) (*browserWindow, error) { return bw, nil }, | |
| 28 | + | } | |
| 29 | + | app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC, Tabs: app.tabs}) | |
| 30 | + | app.tabCoord.SetOnAttachPopupToTab(attach) | |
| 31 | + | app.tabCoord.SetOnTabClosed(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | |
| 32 | + | delete(app.windowForTab, tab.ID) | |
| 33 | + | delete(app.workspaceViews, tab.ID) | |
| 34 | + | app.tabs.Remove(tab.ID) | |
| 35 | + | }) | |
| 36 | + | app.tabCoord.SetOnCurrentWindowEmpty(func(context.Context, coordinator.TabTarget) { | |
| 37 | + | app.cleanupEjectTargetWindow(bw) | |
| 38 | + | }) | |
| 39 | + | return app, bw | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type popupLifecycleWebView struct { | |
| 43 | + | *portmocks.MockWebView | |
| 44 | + | onReady func() | |
| 45 | + | onClose func() | |
| 46 | + | showCalls int | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (w *popupLifecycleWebView) SetOnReadyToShow(fn func()) { w.onReady = fn } | |
| 50 | + | func (w *popupLifecycleWebView) SetOnClose(fn func()) { w.onClose = fn } | |
| 51 | + | func (w *popupLifecycleWebView) Show() { w.showCalls++ } | |
| 52 | + | func (w *popupLifecycleWebView) PrimePopupNavigation(string) {} | |
| 53 | + | ||
| 54 | + | func TestOpenPopupBrowserWindowAdoptsProvidedWebView(t *testing.T) { | |
| 55 | + | wv := portmocks.NewMockWebView(t) | |
| 56 | + | var adopted port.WebView | |
| 57 | + | app, bw := newPopupAdoptionApp(t, func(_ context.Context, _ entity.TabID, _ *entity.Pane, got port.WebView) error { | |
| 58 | + | adopted = got | |
| 59 | + | return nil | |
| 60 | + | }) | |
| 61 | + | ||
| 62 | + | result, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 63 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true, | |
| 64 | + | }) | |
| 65 | + | ||
| 66 | + | require.NoError(t, err) | |
| 67 | + | assert.Same(t, wv, adopted) | |
| 68 | + | assert.Equal(t, bw.id, result.WindowID) | |
| 69 | + | assert.Same(t, bw, app.windowForTab["adopted-tab"]) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestOpenPopupBrowserWindowWaitsForReadySignalAndShowsExactlyOnce(t *testing.T) { | |
| 73 | + | previousShow := showPopupBrowserWindow | |
| 74 | + | showCalls := 0 | |
| 75 | + | showPopupBrowserWindow = func(*browserWindow) { showCalls++ } | |
| 76 | + | t.Cleanup(func() { showPopupBrowserWindow = previousShow }) | |
| 77 | + | ||
| 78 | + | wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 79 | + | app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 80 | + | ||
| 81 | + | _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 82 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: false, | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | require.NoError(t, err) | |
| 86 | + | require.NotNil(t, wv.onReady) | |
| 87 | + | assert.NotNil(t, wv.onClose) | |
| 88 | + | assert.Zero(t, showCalls) | |
| 89 | + | wv.onReady() | |
| 90 | + | wv.onReady() | |
| 91 | + | assert.Equal(t, 1, showCalls) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestOpenPopupBrowserWindowShowsImmediatelyWhenAlreadyReady(t *testing.T) { | |
| 95 | + | previousShow := showPopupBrowserWindow | |
| 96 | + | showCalls := 0 | |
| 97 | + | showPopupBrowserWindow = func(*browserWindow) { showCalls++ } | |
| 98 | + | t.Cleanup(func() { showPopupBrowserWindow = previousShow }) | |
| 99 | + | ||
| 100 | + | wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 101 | + | app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 102 | + | ||
| 103 | + | _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 104 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: true, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | require.NoError(t, err) | |
| 108 | + | assert.Nil(t, wv.onReady) | |
| 109 | + | assert.NotNil(t, wv.onClose) | |
| 110 | + | assert.Equal(t, 1, showCalls) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestOpenPopupBrowserWindowTransferredWebViewIsNotDestroyed(t *testing.T) { | |
| 114 | + | wv := &recordingWebView{id: 99} | |
| 115 | + | app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 116 | + | ||
| 117 | + | _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 118 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: true, | |
| 119 | + | }) | |
| 120 | + | ||
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Zero(t, wv.destroyCalls) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestOpenPopupBrowserWindowCloseRequestClosesAdoptedLastTabAndWindow(t *testing.T) { | |
| 126 | + | wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 127 | + | app, bw := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 128 | + | ||
| 129 | + | _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 130 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: true, | |
| 131 | + | }) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | require.NotNil(t, wv.onClose) | |
| 134 | + | ||
| 135 | + | wv.onClose() | |
| 136 | + | ||
| 137 | + | assert.Zero(t, bw.tabs.Count()) | |
| 138 | + | assert.False(t, app.hasBrowserWindow(bw)) | |
| 139 | + | assert.Empty(t, app.windowForTab) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestOpenPopupBrowserWindowRollsBackShellWhenCreateWithPaneFails(t *testing.T) { | |
| 143 | + | wv := portmocks.NewMockWebView(t) | |
| 144 | + | app, bw := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { | |
| 145 | + | return errors.New("attachment failed") | |
| 146 | + | }) | |
| 147 | + | ||
| 148 | + | _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{ | |
| 149 | + | PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true, | |
| 150 | + | }) | |
| 151 | + | ||
| 152 | + | require.Error(t, err) | |
| 153 | + | assert.False(t, app.hasBrowserWindow(bw)) | |
| 154 | + | assert.Zero(t, bw.tabs.Count()) | |
| 155 | + | assert.Empty(t, app.windowForTab) | |
| 156 | + | assert.Empty(t, app.workspaceViews) | |
| 157 | + | } | |
M internal/ui/browser_window.go +9 -0
| @@ -366,6 +366,7 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 366 | 366 | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | |
| 367 | 367 | ||
| 368 | 368 | a.removeBrowserWindowOrder(id) | |
| 369 | + | a.releaseNativePopupsForBrowserWindow(id) | |
| 369 | 370 | a.releaseBrowserWindowTabs(context.Background(), id, removed) | |
| 370 | 371 | if removed != nil { | |
| 371 | 372 | removed.teardownForDestroy() | |
| @@ -382,6 +383,14 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 382 | 383 | } | |
| 383 | 384 | } | |
| 384 | 385 | ||
| 386 | + | func (a *App) releaseNativePopupsForBrowserWindow(windowID string) { | |
| 387 | + | for popupID, popup := range a.nativePopupWindows { | |
| 388 | + | if popup != nil && popup.parentWindowID == windowID { | |
| 389 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 385 | 394 | func (a *App) removeBrowserWindowOrder(id string) { | |
| 386 | 395 | for i, wid := range a.browserWindowOrder { | |
| 387 | 396 | if wid == id { | |
M internal/ui/browser_window_test.go +26 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -274,6 +275,31 @@ func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *t | |||
| 274 | 275 | assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID)) | |
| 275 | 276 | } | |
| 276 | 277 | ||
| 278 | + | func TestBrowserWindow_RemoveBrowserWindowReleasesOnlyRelatedNativePopups(t *testing.T) { | |
| 279 | + | closedWebView := portmocks.NewMockWebView(t) | |
| 280 | + | closedWebView.EXPECT().IsDestroyed().Return(false).Once() | |
| 281 | + | closedWebView.EXPECT().Destroy().Once() | |
| 282 | + | unrelatedWebView := portmocks.NewMockWebView(t) | |
| 283 | + | closedShell := &popupDestroySpy{} | |
| 284 | + | unrelatedShell := &popupDestroySpy{} | |
| 285 | + | removed := &browserWindow{id: "owner-window", tabs: entity.NewTabList()} | |
| 286 | + | remaining := &browserWindow{id: "other-window", tabs: entity.NewTabList()} | |
| 287 | + | app := &App{ | |
| 288 | + | browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining}, | |
| 289 | + | nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | |
| 290 | + | 1: {popupID: 1, parentWindowID: removed.id, webView: closedWebView, popupWindow: closedShell}, | |
| 291 | + | 2: {popupID: 2, parentWindowID: remaining.id, webView: unrelatedWebView, popupWindow: unrelatedShell}, | |
| 292 | + | }, | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | app.removeBrowserWindow(removed.id) | |
| 296 | + | ||
| 297 | + | assert.NotContains(t, app.nativePopupWindows, port.WebViewID(1)) | |
| 298 | + | assert.Contains(t, app.nativePopupWindows, port.WebViewID(2)) | |
| 299 | + | assert.Equal(t, 1, closedShell.destroyed) | |
| 300 | + | assert.Zero(t, unrelatedShell.destroyed) | |
| 301 | + | } | |
| 302 | + | ||
| 277 | 303 | func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | |
| 278 | 304 | ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned"))) | |
| 279 | 305 | otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other"))) | |
A internal/ui/coordinator/content/browsing_context_observability_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestLogBrowsingContextDecisionUsesStructuredSafeFields(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | ||
| 18 | + | var output bytes.Buffer | |
| 19 | + | logger := zerolog.New(&output) | |
| 20 | + | req := dto.NewBrowsingContextRequest{ | |
| 21 | + | Engine: dto.BrowserEngineWebKit, SourceHost: dto.SourceHostFloating, | |
| 22 | + | TargetURI: "https://example.com/private?token=secret", TargetDisposition: dto.WindowDispositionNewPopup, | |
| 23 | + | } | |
| 24 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow, ReasonCode: dto.HostDecisionReasonFloatingBrowserWindow} | |
| 25 | + | ||
| 26 | + | logBrowsingContextDecision(logger, req, decision) | |
| 27 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 28 | + | assert.Equal(t, "webkit", record["engine"]) | |
| 29 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 30 | + | assert.Equal(t, "create-browser-window", record["decision"]) | |
| 31 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 32 | + | assert.Equal(t, "floating-browser-window", record["reason_code"]) | |
| 33 | + | assert.NotContains(t, output.String(), "token") | |
| 34 | + | assert.NotContains(t, output.String(), "secret") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestLogBrowsingContextFailureUsesTypedReasonCode(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 39 | + | ||
| 40 | + | var output bytes.Buffer | |
| 41 | + | logger := zerolog.New(&output) | |
| 42 | + | req := dto.NewBrowsingContextRequest{Engine: dto.BrowserEngineCEF, SourceHost: dto.SourceHostWorkspace, TargetDisposition: dto.WindowDispositionNewTab} | |
| 43 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup} | |
| 44 | + | ||
| 45 | + | logBrowsingContextFailure(logger, req, decision, dto.BrowsingContextFailureHostFailed, errors.New("attachment failed")) | |
| 46 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 47 | + | assert.Equal(t, "cef", record["engine"]) | |
| 48 | + | assert.Equal(t, "workspace", record["source_host"]) | |
| 49 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 50 | + | assert.Equal(t, "new-tab", record["target_disposition"]) | |
| 51 | + | assert.Equal(t, "host-failed", record["reason_code"]) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func decodeBrowsingContextLog(t *testing.T, data []byte) map[string]any { | |
| 55 | + | t.Helper() | |
| 56 | + | var record map[string]any | |
| 57 | + | require.NoError(t, json.Unmarshal(data, &record)) | |
| 58 | + | for _, field := range []string{"engine", "source_host", "decision", "target_disposition", "reason_code"} { | |
| 59 | + | assert.Contains(t, record, field) | |
| 60 | + | } | |
| 61 | + | return record | |
| 62 | + | } | |
M internal/ui/coordinator/content/browsing_context_policy.go +31 -73
| @@ -5,59 +5,40 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/rs/zerolog" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | - | type browsingContextPolicy struct{} | |
| 11 | - | ||
| 12 | - | func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision { | |
| 13 | - | decision := dto.HostDecision{ | |
| 14 | - | RequestContextDisposition: req.RequestContextDisposition, | |
| 15 | - | RequiresNativeOpener: req.RequiresNativeOpener, | |
| 16 | - | } | |
| 17 | - | if decision.RequestContextDisposition == "" { | |
| 18 | - | decision.RequestContextDisposition = dto.RequestContextInheritParent | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | if strings.TrimSpace(req.TargetURI) == "" { | |
| 22 | - | decision.Kind = dto.HostDecisionDeny | |
| 23 | - | decision.Reason = "empty target URI" | |
| 24 | - | return decision | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | if req.AuthIntent { | |
| 28 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 29 | - | decision.Reason = "auth intent requires native window" | |
| 30 | - | return decision | |
| 31 | - | } | |
| 32 | - | if req.RequiresNativeOpener { | |
| 33 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 34 | - | decision.Reason = "native opener required" | |
| 35 | - | return decision | |
| 36 | - | } | |
| 37 | - | if isAmbiguousNativeBrowsingContext(req) { | |
| 38 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 39 | - | decision.Reason = "ambiguous opener-coupled request prefers native window" | |
| 40 | - | return decision | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | name := reusableBrowsingContextName(req.TargetFrameName) | |
| 44 | - | if namedContextExists && name != "" { | |
| 45 | - | decision.Kind = dto.HostDecisionReuseNamedPane | |
| 46 | - | decision.ReuseContextName = name | |
| 47 | - | decision.BrowsingContextName = name | |
| 48 | - | decision.Reason = "reuse named browsing context in current window" | |
| 49 | - | return decision | |
| 50 | - | } | |
| 12 | + | func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) { | |
| 13 | + | logger.Debug(). | |
| 14 | + | Str("engine", string(req.Engine)). | |
| 15 | + | Str("source_host", string(req.SourceHost)). | |
| 16 | + | Str("decision", string(decision.Kind)). | |
| 17 | + | Str("target_disposition", string(req.TargetDisposition)). | |
| 18 | + | Str("reason_code", string(decision.ReasonCode)). | |
| 19 | + | Msg("browsing context host decided") | |
| 20 | + | } | |
| 51 | 21 | ||
| 52 | - | decision.Kind = dto.HostDecisionCreatePane | |
| 53 | - | decision.BrowsingContextName = name | |
| 54 | - | decision.Reason = "pane-hosted browsing context" | |
| 55 | - | return decision | |
| 22 | + | func logBrowsingContextFailure( | |
| 23 | + | logger zerolog.Logger, | |
| 24 | + | req dto.NewBrowsingContextRequest, | |
| 25 | + | decision dto.HostDecision, | |
| 26 | + | code dto.BrowsingContextFailureCode, | |
| 27 | + | err error, | |
| 28 | + | ) { | |
| 29 | + | logger.Error().Err(err). | |
| 30 | + | Str("engine", string(req.Engine)). | |
| 31 | + | Str("source_host", string(req.SourceHost)). | |
| 32 | + | Str("decision", string(decision.Kind)). | |
| 33 | + | Str("target_disposition", string(req.TargetDisposition)). | |
| 34 | + | Str("reason_code", string(code)). | |
| 35 | + | Msg("browsing context hosting failed") | |
| 56 | 36 | } | |
| 57 | 37 | ||
| 58 | 38 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | |
| 59 | 39 | return dto.NewBrowsingContextRequest{ | |
| 60 | 40 | ParentWebViewID: uint64(req.ParentViewID), | |
| 41 | + | Engine: req.Engine, | |
| 61 | 42 | SourceBrowserID: req.SourceBrowserID, | |
| 62 | 43 | SourceFrameID: req.SourceFrameID, | |
| 63 | 44 | SourceFrameURL: req.SourceFrameURL, | |
| @@ -66,7 +47,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont | |||
| 66 | 47 | TargetDisposition: inferPopupWindowDisposition(req), | |
| 67 | 48 | IsUserGesture: req.IsUserGesture, | |
| 68 | 49 | NoJavaScriptAccess: req.NoJavaScriptAccess, | |
| 69 | - | WindowFeatures: req.WindowFeatures, | |
| 50 | + | PopupFeatures: req.PopupFeatures, | |
| 70 | 51 | TriggerKind: inferPopupTriggerKind(req), | |
| 71 | 52 | AuthIntent: IsOAuthURL(req.TargetURI), | |
| 72 | 53 | RequiresNativeOpener: false, | |
| @@ -99,38 +80,15 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition { | |||
| 99 | 80 | } | |
| 100 | 81 | ||
| 101 | 82 | func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind { | |
| 83 | + | if usecase.ReusableBrowsingContextName(req.FrameName) != "" { | |
| 84 | + | return dto.TriggerNamedTargetNavigation | |
| 85 | + | } | |
| 102 | 86 | switch inferPopupWindowDisposition(req) { | |
| 103 | - | case dto.WindowDispositionCurrentTab: | |
| 104 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |
| 105 | - | return dto.TriggerNamedTargetNavigation | |
| 106 | - | } | |
| 107 | - | return dto.TriggerLinkNewPage | |
| 108 | - | case dto.WindowDispositionNewTab: | |
| 109 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |
| 110 | - | return dto.TriggerNamedTargetNavigation | |
| 111 | - | } | |
| 87 | + | case dto.WindowDispositionCurrentTab, dto.WindowDispositionNewTab: | |
| 112 | 88 | return dto.TriggerLinkNewPage | |
| 113 | 89 | case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow: | |
| 114 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |
| 115 | - | return dto.TriggerNamedTargetNavigation | |
| 116 | - | } | |
| 117 | 90 | return dto.TriggerScriptWindowOpen | |
| 118 | 91 | default: | |
| 119 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |
| 120 | - | return dto.TriggerNamedTargetNavigation | |
| 121 | - | } | |
| 122 | 92 | return dto.TriggerUnknown | |
| 123 | 93 | } | |
| 124 | 94 | } | |
| 125 | - | ||
| 126 | - | func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool { | |
| 127 | - | return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func reusableBrowsingContextName(frameName string) string { | |
| 131 | - | name := strings.TrimSpace(frameName) | |
| 132 | - | if name == "" || strings.EqualFold(name, "_blank") { | |
| 133 | - | return "" | |
| 134 | - | } | |
| 135 | - | return name | |
| 136 | - | } | |
M internal/ui/coordinator/content/browsing_context_policy_test.go +12 -155
| @@ -8,167 +8,24 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestBrowsingContextPolicyDecide(t *testing.T) { | |
| 11 | + | func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) { | |
| 12 | 12 | t.Parallel() | |
| 13 | 13 | ||
| 14 | - | policy := browsingContextPolicy{} | |
| 15 | - | tests := []struct { | |
| 16 | - | name string | |
| 17 | - | request dto.NewBrowsingContextRequest | |
| 18 | - | namedContextExist bool | |
| 19 | - | wantKind dto.HostDecisionKind | |
| 20 | - | wantName string | |
| 21 | - | }{ | |
| 22 | - | { | |
| 23 | - | name: "blank target stays pane-hosted", | |
| 24 | - | request: dto.NewBrowsingContextRequest{ | |
| 25 | - | TargetURI: "https://example.com/docs", | |
| 26 | - | TargetFrameName: "_blank", | |
| 27 | - | TriggerKind: dto.TriggerLinkNewPage, | |
| 28 | - | TargetDisposition: dto.WindowDispositionNewTab, | |
| 29 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 30 | - | }, | |
| 31 | - | wantKind: dto.HostDecisionCreatePane, | |
| 32 | - | }, | |
| 33 | - | { | |
| 34 | - | name: "named target reuses existing pane in same window", | |
| 35 | - | request: dto.NewBrowsingContextRequest{ | |
| 36 | - | TargetURI: "https://example.com/second", | |
| 37 | - | TargetFrameName: "shared-pane", | |
| 38 | - | TriggerKind: dto.TriggerNamedTargetNavigation, | |
| 39 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 40 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 41 | - | }, | |
| 42 | - | namedContextExist: true, | |
| 43 | - | wantKind: dto.HostDecisionReuseNamedPane, | |
| 44 | - | wantName: "shared-pane", | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | name: "ordinary window.open stays pane-hosted", | |
| 48 | - | request: dto.NewBrowsingContextRequest{ | |
| 49 | - | TargetURI: "https://example.com/help", | |
| 50 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |
| 51 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 52 | - | NoJavaScriptAccess: false, | |
| 53 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 54 | - | }, | |
| 55 | - | wantKind: dto.HostDecisionCreatePane, | |
| 56 | - | }, | |
| 57 | - | { | |
| 58 | - | name: "auth intent forces native window", | |
| 59 | - | request: dto.NewBrowsingContextRequest{ | |
| 60 | - | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 61 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |
| 62 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 63 | - | AuthIntent: true, | |
| 64 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 65 | - | }, | |
| 66 | - | wantKind: dto.HostDecisionCreateNativeWin, | |
| 67 | - | }, | |
| 68 | - | { | |
| 69 | - | name: "ambiguous opener-coupled request prefers native", | |
| 70 | - | request: dto.NewBrowsingContextRequest{ | |
| 71 | - | TargetURI: "https://example.com/popup", | |
| 72 | - | TriggerKind: dto.TriggerUnknown, | |
| 73 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 74 | - | NoJavaScriptAccess: false, | |
| 75 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 76 | - | }, | |
| 77 | - | wantKind: dto.HostDecisionCreateNativeWin, | |
| 78 | - | }, | |
| 79 | - | { | |
| 80 | - | name: "empty target is denied", | |
| 81 | - | request: dto.NewBrowsingContextRequest{ | |
| 82 | - | TargetURI: "", | |
| 83 | - | TriggerKind: dto.TriggerLinkNewPage, | |
| 84 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 85 | - | }, | |
| 86 | - | wantKind: dto.HostDecisionDeny, | |
| 87 | - | }, | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | for _, tt := range tests { | |
| 91 | - | t.Run(tt.name, func(t *testing.T) { | |
| 92 | - | t.Parallel() | |
| 93 | - | got := policy.Decide(tt.request, tt.namedContextExist) | |
| 94 | - | assert.Equal(t, tt.wantKind, got.Kind) | |
| 95 | - | assert.Equal(t, tt.wantName, got.BrowsingContextName) | |
| 96 | - | assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition) | |
| 97 | - | }) | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) { | |
| 102 | - | t.Parallel() | |
| 103 | - | ||
| 104 | - | req := port.PopupRequest{ | |
| 105 | - | TargetURI: "https://example.com/window-open", | |
| 106 | - | NoJavaScriptAccess: false, | |
| 107 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req)) | |
| 14 | + | features := dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 320, WidthSet: true} | |
| 15 | + | got := buildPopupBrowsingContextRequest(port.PopupRequest{Engine: dto.BrowserEngineCEF, PopupFeatures: features, TargetURI: "https://example.com", ParentViewID: 42}) | |
| 16 | + | assert.Equal(t, dto.BrowserEngineCEF, got.Engine) | |
| 17 | + | assert.Equal(t, features, got.PopupFeatures) | |
| 111 | 18 | } | |
| 112 | 19 | ||
| 113 | - | func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) { | |
| 20 | + | func TestInferPopupTriggerKind(t *testing.T) { | |
| 114 | 21 | t.Parallel() | |
| 115 | - | ||
| 116 | - | tests := []struct { | |
| 117 | - | name string | |
| 118 | - | req port.PopupRequest | |
| 119 | - | want dto.TriggerKind | |
| 120 | - | }{ | |
| 121 | - | { | |
| 122 | - | name: "unnamed current-tab request stays a navigation", | |
| 123 | - | req: port.PopupRequest{ | |
| 124 | - | TargetURI: "https://example.com/docs", | |
| 125 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |
| 126 | - | }, | |
| 127 | - | want: dto.TriggerLinkNewPage, | |
| 128 | - | }, | |
| 129 | - | { | |
| 130 | - | name: "named current-tab request is treated as named target navigation", | |
| 131 | - | req: port.PopupRequest{ | |
| 132 | - | TargetURI: "https://example.com/docs", | |
| 133 | - | FrameName: "shared-pane", | |
| 134 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |
| 135 | - | }, | |
| 136 | - | want: dto.TriggerNamedTargetNavigation, | |
| 137 | - | }, | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | for _, tt := range tests { | |
| 141 | - | t.Run(tt.name, func(t *testing.T) { | |
| 142 | - | t.Parallel() | |
| 143 | - | assert.Equal(t, tt.want, inferPopupTriggerKind(tt.req)) | |
| 144 | - | }) | |
| 145 | - | } | |
| 22 | + | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(port.PopupRequest{TargetURI: "https://example.com/window-open", TargetDisposition: dto.WindowDispositionNewPopup})) | |
| 23 | + | assert.Equal(t, dto.TriggerLinkNewPage, inferPopupTriggerKind(port.PopupRequest{TargetDisposition: dto.WindowDispositionCurrentTab})) | |
| 24 | + | assert.Equal(t, dto.TriggerNamedTargetNavigation, inferPopupTriggerKind(port.PopupRequest{FrameName: "shared", TargetDisposition: dto.WindowDispositionCurrentTab})) | |
| 146 | 25 | } | |
| 147 | 26 | ||
| 148 | - | func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) { | |
| 27 | + | func TestInferPopupWindowDisposition(t *testing.T) { | |
| 149 | 28 | t.Parallel() | |
| 150 | - | ||
| 151 | - | tests := []struct { | |
| 152 | - | name string | |
| 153 | - | req port.PopupRequest | |
| 154 | - | want dto.WindowDisposition | |
| 155 | - | }{ | |
| 156 | - | { | |
| 157 | - | name: "mixed-case blank target is treated as new tab", | |
| 158 | - | req: port.PopupRequest{FrameName: " _BlAnK "}, | |
| 159 | - | want: dto.WindowDispositionNewTab, | |
| 160 | - | }, | |
| 161 | - | { | |
| 162 | - | name: "named target still defaults to popup", | |
| 163 | - | req: port.PopupRequest{FrameName: "shared-pane"}, | |
| 164 | - | want: dto.WindowDispositionNewPopup, | |
| 165 | - | }, | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | for _, tt := range tests { | |
| 169 | - | t.Run(tt.name, func(t *testing.T) { | |
| 170 | - | t.Parallel() | |
| 171 | - | assert.Equal(t, tt.want, inferPopupWindowDisposition(tt.req)) | |
| 172 | - | }) | |
| 173 | - | } | |
| 29 | + | assert.Equal(t, dto.WindowDispositionNewTab, inferPopupWindowDisposition(port.PopupRequest{FrameName: " _BlAnK "})) | |
| 30 | + | assert.Equal(t, dto.WindowDispositionNewPopup, inferPopupWindowDisposition(port.PopupRequest{FrameName: "shared-pane"})) | |
| 174 | 31 | } | |
M internal/ui/coordinator/content/lifecycle.go +1 -0
| @@ -48,6 +48,7 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||
| 48 | 48 | // ReleaseWebView returns the WebView for a pane to the pool. | |
| 49 | 49 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | |
| 50 | 50 | log := logging.FromContext(ctx) | |
| 51 | + | c.ensurePopupManager().cancelDeferredPopupsForParent(paneID) | |
| 51 | 52 | ||
| 52 | 53 | // deleteWebViewLocked clears reveal state before returning, even when a | |
| 53 | 54 | // previous cleanup already removed this pane's mapping. | |
M internal/ui/coordinator/content/lifecycle_test.go +1 -1
| @@ -223,7 +223,7 @@ func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *test | |||
| 223 | 223 | return "", false | |
| 224 | 224 | }) | |
| 225 | 225 | c.webViews[paneID] = wv | |
| 226 | - | c.popups.namedContexts.Register(windowID, "named-popup", paneID, wv.ID()) | |
| 226 | + | c.popups.namedContexts.Register(windowID, windowID, "named-popup", paneID, wv.ID()) | |
| 227 | 227 | ||
| 228 | 228 | _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane) | |
| 229 | 229 | require.True(t, okBefore) | |
M internal/ui/coordinator/content/named_context_registry.go +13 -13
| @@ -8,13 +8,14 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | type namedBrowsingContextKey struct { | |
| 11 | - | WindowID string | |
| 12 | - | Name string | |
| 11 | + | OwnerWindowID string | |
| 12 | + | Name string | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 15 | type namedBrowsingContextState struct { | |
| 16 | - | PaneID entity.PaneID | |
| 17 | - | WebViewID port.WebViewID | |
| 16 | + | PaneID entity.PaneID | |
| 17 | + | WebViewID port.WebViewID | |
| 18 | + | HostWindowID string | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | type namedBrowsingContextRegistry struct { | |
| @@ -28,14 +29,13 @@ func newNamedBrowsingContextRegistry() *namedBrowsingContextRegistry { | |||
| 28 | 29 | } | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | - | func (r *namedBrowsingContextRegistry) Register(windowID, name string, paneID entity.PaneID, webViewID port.WebViewID) { | |
| 32 | - | if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 { | |
| 32 | + | func (r *namedBrowsingContextRegistry) Register(ownerWindowID, hostWindowID, name string, paneID entity.PaneID, webViewID port.WebViewID) { | |
| 33 | + | if r == nil || ownerWindowID == "" || hostWindowID == "" || name == "" || paneID == "" || webViewID == 0 { | |
| 33 | 34 | return | |
| 34 | 35 | } | |
| 35 | 36 | r.mu.Lock() | |
| 36 | - | r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{ | |
| 37 | - | PaneID: paneID, | |
| 38 | - | WebViewID: webViewID, | |
| 37 | + | r.contexts[namedBrowsingContextKey{OwnerWindowID: ownerWindowID, Name: name}] = namedBrowsingContextState{ | |
| 38 | + | PaneID: paneID, WebViewID: webViewID, HostWindowID: hostWindowID, | |
| 39 | 39 | } | |
| 40 | 40 | r.mu.Unlock() | |
| 41 | 41 | } | |
| @@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||
| 49 | 49 | return namedBrowsingContextState{}, nil, false | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | key := namedBrowsingContextKey{WindowID: windowID, Name: name} | |
| 52 | + | key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name} | |
| 53 | 53 | r.mu.RLock() | |
| 54 | 54 | state, ok := r.contexts[key] | |
| 55 | 55 | r.mu.RUnlock() | |
| @@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | currentWindowID, ok := resolveWindowID(state.PaneID) | |
| 61 | - | if !ok || currentWindowID != windowID { | |
| 61 | + | if !ok || currentWindowID != state.HostWindowID { | |
| 62 | 62 | r.deleteIfStateMatches(key, state) | |
| 63 | 63 | return namedBrowsingContextState{}, nil, false | |
| 64 | 64 | } | |
| @@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) { | |||
| 114 | 114 | return | |
| 115 | 115 | } | |
| 116 | 116 | r.mu.Lock() | |
| 117 | - | for key := range r.contexts { | |
| 118 | - | if key.WindowID == windowID { | |
| 117 | + | for key, state := range r.contexts { | |
| 118 | + | if key.OwnerWindowID == windowID || state.HostWindowID == windowID { | |
| 119 | 119 | delete(r.contexts, key) | |
| 120 | 120 | } | |
| 121 | 121 | } | |
M internal/ui/coordinator/content/named_context_registry_test.go +37 -7
| @@ -15,7 +15,7 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T) | |||
| 15 | 15 | reg := newNamedBrowsingContextRegistry() | |
| 16 | 16 | paneID := entity.PaneID("pane-1") | |
| 17 | 17 | webViewID := port.WebViewID(41) | |
| 18 | - | reg.Register("window-1", "shared-pane", paneID, webViewID) | |
| 18 | + | reg.Register("window-1", "window-1", "shared-pane", paneID, webViewID) | |
| 19 | 19 | ||
| 20 | 20 | wv := mocks.NewMockWebView(t) | |
| 21 | 21 | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| @@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T) | |||
| 39 | 39 | assert.Same(t, wv, gotWV) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestNamedBrowsingContextRegistry_LookupUsesOwnerScopeAndDetachedHostLiveness(t *testing.T) { | |
| 43 | + | t.Parallel() | |
| 44 | + | reg := newNamedBrowsingContextRegistry() | |
| 45 | + | paneID := entity.PaneID("detached-pane") | |
| 46 | + | webViewID := port.WebViewID(51) | |
| 47 | + | reg.Register("owner-window", "host-window", "named", paneID, webViewID) | |
| 48 | + | wv := mocks.NewMockWebView(t) | |
| 49 | + | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| 50 | + | wv.EXPECT().ID().Return(webViewID).Once() | |
| 51 | + | ||
| 52 | + | state, got, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return wv }, func(entity.PaneID) (string, bool) { | |
| 53 | + | return "host-window", true | |
| 54 | + | }) | |
| 55 | + | ||
| 56 | + | assert.True(t, ok) | |
| 57 | + | assert.Equal(t, "host-window", state.HostWindowID) | |
| 58 | + | assert.Same(t, wv, got) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestNamedBrowsingContextRegistry_UnregisterWindowRemovesOwnerAndHostEntries(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | for _, closedWindow := range []string{"owner-window", "host-window"} { | |
| 64 | + | reg := newNamedBrowsingContextRegistry() | |
| 65 | + | reg.Register("owner-window", "host-window", "named", "detached-pane", 51) | |
| 66 | + | reg.UnregisterWindow(closedWindow) | |
| 67 | + | _, _, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return nil }, func(entity.PaneID) (string, bool) { return "host-window", true }) | |
| 68 | + | assert.False(t, ok, "closing %s must unregister context", closedWindow) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 42 | 72 | func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) { | |
| 43 | 73 | t.Parallel() | |
| 44 | 74 | ||
| 45 | 75 | reg := newNamedBrowsingContextRegistry() | |
| 46 | - | reg.Register("window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41)) | |
| 76 | + | reg.Register("window-1", "window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41)) | |
| 47 | 77 | ||
| 48 | 78 | _, _, ok := reg.Lookup( | |
| 49 | 79 | "window-2", | |
| @@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi | |||
| 60 | 90 | ||
| 61 | 91 | reg := newNamedBrowsingContextRegistry() | |
| 62 | 92 | paneID := entity.PaneID("pane-1") | |
| 63 | - | reg.Register("window-1", "shared-pane", paneID, port.WebViewID(41)) | |
| 93 | + | reg.Register("window-1", "window-1", "shared-pane", paneID, port.WebViewID(41)) | |
| 64 | 94 | ||
| 65 | 95 | _, _, ok := reg.Lookup( | |
| 66 | 96 | "window-1", | |
| @@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||
| 86 | 116 | oldPaneID := entity.PaneID("pane-old") | |
| 87 | 117 | newPaneID := entity.PaneID("pane-new") | |
| 88 | 118 | newWebViewID := port.WebViewID(42) | |
| 89 | - | reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41)) | |
| 119 | + | reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41)) | |
| 90 | 120 | ||
| 91 | 121 | _, _, ok := reg.Lookup( | |
| 92 | 122 | "window-1", | |
| @@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||
| 94 | 124 | func(entity.PaneID) port.WebView { return nil }, | |
| 95 | 125 | func(gotPaneID entity.PaneID) (string, bool) { | |
| 96 | 126 | assert.Equal(t, oldPaneID, gotPaneID) | |
| 97 | - | reg.Register("window-1", "shared-pane", newPaneID, newWebViewID) | |
| 127 | + | reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID) | |
| 98 | 128 | return "window-2", true | |
| 99 | 129 | }, | |
| 100 | 130 | ) | |
| @@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS | |||
| 128 | 158 | oldPaneID := entity.PaneID("pane-old") | |
| 129 | 159 | newPaneID := entity.PaneID("pane-new") | |
| 130 | 160 | newWebViewID := port.WebViewID(42) | |
| 131 | - | reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41)) | |
| 161 | + | reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41)) | |
| 132 | 162 | ||
| 133 | 163 | _, _, ok := reg.Lookup( | |
| 134 | 164 | "window-1", | |
| 135 | 165 | "shared-pane", | |
| 136 | 166 | func(gotPaneID entity.PaneID) port.WebView { | |
| 137 | 167 | assert.Equal(t, oldPaneID, gotPaneID) | |
| 138 | - | reg.Register("window-1", "shared-pane", newPaneID, newWebViewID) | |
| 168 | + | reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID) | |
| 139 | 169 | return nil | |
| 140 | 170 | }, | |
| 141 | 171 | func(gotPaneID entity.PaneID) (string, bool) { | |
M internal/ui/coordinator/content/popup.go +59 -8
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -105,14 +106,45 @@ type InsertPopupInput struct { | |||
| 105 | 106 | ||
| 106 | 107 | // NativePopupInput contains the data needed to host a native-required popup in | |
| 107 | 108 | // a dedicated top-level GTK window instead of the workspace. | |
| 109 | + | // BrowserWindowInput contains an engine-created related WebView to adopt into | |
| 110 | + | // a complete Dumber browser window. A successful callback transfers ownership. | |
| 111 | + | type BrowserWindowInput struct { | |
| 112 | + | ParentPaneID entity.PaneID | |
| 113 | + | ParentWebViewID port.WebViewID | |
| 114 | + | PopupPane *entity.Pane | |
| 115 | + | PopupWebView port.WebView | |
| 116 | + | TargetURI string | |
| 117 | + | Request port.PopupRequest | |
| 118 | + | Ready bool | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | type BrowserWindowResult struct { | |
| 122 | + | WindowID string | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // PopupStagingHost temporarily owns a popup widget while late engine metadata | |
| 126 | + | // is unavailable. Detach transfers the widget without destroying it. | |
| 127 | + | type PopupStagingHost interface { | |
| 128 | + | Detach() error | |
| 129 | + | Destroy() | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | type StagePopupInput struct { | |
| 133 | + | PopupWebView port.WebView | |
| 134 | + | } | |
| 135 | + | ||
| 108 | 136 | type NativePopupInput struct { | |
| 109 | - | ParentPaneID entity.PaneID | |
| 110 | - | ParentWebViewID port.WebViewID | |
| 111 | - | ParentURIAtOpen string | |
| 112 | - | PopupWebView port.WebView | |
| 113 | - | TargetURI string | |
| 114 | - | Request port.PopupRequest | |
| 115 | - | ObserveOAuthAutoClose bool | |
| 137 | + | ParentPaneID entity.PaneID | |
| 138 | + | ParentWebViewID port.WebViewID | |
| 139 | + | ParentURIAtOpen string | |
| 140 | + | PopupWebView port.WebView | |
| 141 | + | TargetURI string | |
| 142 | + | Request port.PopupRequest | |
| 143 | + | ObserveOAuthAutoClose bool | |
| 144 | + | AllowBrowserWindowFallback bool | |
| 145 | + | // OnNativeHostAbort returns true when fallback adopted the WebView. A false | |
| 146 | + | // result leaves destruction responsibility with the native host. | |
| 147 | + | OnNativeHostAbort func(context.Context, port.WebView) bool | |
| 116 | 148 | } | |
| 117 | 149 | ||
| 118 | 150 | // GetBehavior returns the appropriate behavior based on popup type and config. | |
| @@ -159,8 +191,27 @@ func (c *Coordinator) SetPopupWindowIDResolver(fn func(entity.PaneID) (string, b | |||
| 159 | 191 | c.ensurePopupManager().setWindowIDResolver(fn) | |
| 160 | 192 | } | |
| 161 | 193 | ||
| 194 | + | func (c *Coordinator) SetPopupSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) { | |
| 195 | + | c.ensurePopupManager().setSourceHostResolver(fn) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (c *Coordinator) SetOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) { | |
| 199 | + | c.ensurePopupManager().setOnOpenBrowserWindow(fn) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (c *Coordinator) SetOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) { | |
| 203 | + | c.ensurePopupManager().setOnStagePopup(fn) | |
| 204 | + | } | |
| 205 | + | ||
| 162 | 206 | func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) { | |
| 163 | - | c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID) | |
| 207 | + | pm := c.ensurePopupManager() | |
| 208 | + | pm.cancelDeferredPopupsForWindow(windowID) | |
| 209 | + | pm.clearReusableNamedPopupsForWindow(windowID) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // ShutdownPopups releases unresolved staged popups before the browser engine exits. | |
| 213 | + | func (c *Coordinator) ShutdownPopups() { | |
| 214 | + | c.ensurePopupManager().cancelAllDeferredPopups() | |
| 164 | 215 | } | |
| 165 | 216 | ||
| 166 | 217 | // SetOnInsertPopup sets the callback to insert popups into the workspace. | |
A internal/ui/coordinator/content/popup_browsing_context_test.go +846 -0
| @@ -0,0 +1,846 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/rs/zerolog" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 18 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 19 | + | domainerrors "github.com/bnema/dumber/internal/domain/errors" | |
| 20 | + | "github.com/bnema/dumber/internal/logging" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // --------------------------------------------------------------------------- | |
| 24 | + | // DetectPopupType | |
| 25 | + | // --------------------------------------------------------------------------- | |
| 26 | + | ||
| 27 | + | func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) { | |
| 28 | + | t.Helper() | |
| 29 | + | ||
| 30 | + | ctx := context.Background() | |
| 31 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 32 | + | parentWV := mocks.NewMockWebView(t) | |
| 33 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 34 | + | ||
| 35 | + | popupWV := mocks.NewMockWebView(t) | |
| 36 | + | popupWV.EXPECT().ID().Return(popupID).Maybe() | |
| 37 | + | ||
| 38 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 39 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 40 | + | ||
| 41 | + | c := &Coordinator{ | |
| 42 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 43 | + | popups: newPopupManager(), | |
| 44 | + | } | |
| 45 | + | c.SetPopupConfig(factory, nil, func() string { return "popup-pane" }) | |
| 46 | + | ||
| 47 | + | return ctx, parentPaneID, parentWV, popupWV, c | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) { | |
| 51 | + | ctx := context.Background() | |
| 52 | + | parentPaneID := entity.PaneID("floating") | |
| 53 | + | parentWV := mocks.NewMockWebView(t) | |
| 54 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 55 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once() | |
| 56 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 57 | + | popupWV.EXPECT().Destroy().Once() | |
| 58 | + | ||
| 59 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 60 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 61 | + | c := &Coordinator{popups: newPopupManager()} | |
| 62 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 63 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 64 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 65 | + | t.Fatal("floating request must not use workspace insertion") | |
| 66 | + | return nil | |
| 67 | + | }) | |
| 68 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 69 | + | t.Fatal("featureless _blank must not open a browser window") | |
| 70 | + | return BrowserWindowResult{}, nil | |
| 71 | + | }) | |
| 72 | + | ||
| 73 | + | got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 74 | + | Engine: dto.BrowserEngineWebKit, | |
| 75 | + | TargetURI: "https://example.com/new", | |
| 76 | + | FrameName: "_blank", | |
| 77 | + | TargetDisposition: dto.WindowDispositionNewTab, | |
| 78 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | assert.Nil(t, got, "featureless link popup must be discarded after source navigation") | |
| 82 | + | decision, ok := popupWV.BrowsingContextHostDecision() | |
| 83 | + | require.True(t, ok) | |
| 84 | + | assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 88 | + | ctx := context.Background() | |
| 89 | + | parentPaneID := entity.PaneID("floating") | |
| 90 | + | parentWV := mocks.NewMockWebView(t) | |
| 91 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 92 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once() | |
| 93 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 94 | + | c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()} | |
| 95 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 96 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 97 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 98 | + | t.Fatal("floating middle-click must not use workspace insertion") | |
| 99 | + | return nil | |
| 100 | + | }) | |
| 101 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 102 | + | t.Fatal("floating middle-click must not open a browser window") | |
| 103 | + | return BrowserWindowResult{}, nil | |
| 104 | + | }) | |
| 105 | + | ||
| 106 | + | assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle")) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestPopupWorkspaceBlankRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 110 | + | parentWV := mocks.NewMockWebView(t) | |
| 111 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 112 | + | c := &Coordinator{popups: newPopupManager()} | |
| 113 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 114 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace }) | |
| 115 | + | ||
| 116 | + | got := c.handlePopupCreate(context.Background(), "workspace", parentWV, port.PopupRequest{TargetURI: "https://example.com/new"}) | |
| 117 | + | ||
| 118 | + | assert.Nil(t, got) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestPopupWorkspaceMiddleClickRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 122 | + | c := &Coordinator{popups: newPopupManager()} | |
| 123 | + | c.SetPopupConfig(mocks.NewMockWebViewFactory(t), &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 124 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace }) | |
| 125 | + | ||
| 126 | + | assert.False(t, c.handleLinkMiddleClick(context.Background(), "workspace", "https://example.com/middle")) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestPopupFloatingNamedContextReusesDetachedWindow(t *testing.T) { | |
| 130 | + | ctx := context.Background() | |
| 131 | + | parentPaneID := entity.PaneID("floating") | |
| 132 | + | parentWV := mocks.NewMockWebView(t) | |
| 133 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 134 | + | popupWV := mocks.NewMockWebView(t) | |
| 135 | + | popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 136 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 137 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 138 | + | popupWV.EXPECT().IsDestroyed().Return(false).Twice() | |
| 139 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once() | |
| 140 | + | ||
| 141 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 142 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 143 | + | c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()} | |
| 144 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 145 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 146 | + | windowByPane := map[entity.PaneID]string{parentPaneID: "owner-window"} | |
| 147 | + | c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) { | |
| 148 | + | windowID, ok := windowByPane[paneID] | |
| 149 | + | return windowID, ok | |
| 150 | + | }) | |
| 151 | + | hostCalls := 0 | |
| 152 | + | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 153 | + | hostCalls++ | |
| 154 | + | windowByPane[input.PopupPane.ID] = "detached-window" | |
| 155 | + | c.RegisterPopupWebView(input.PopupPane.ID, input.PopupWebView) | |
| 156 | + | return BrowserWindowResult{WindowID: "detached-window"}, nil | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 160 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/first", FrameName: "shared", | |
| 161 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 162 | + | }) | |
| 163 | + | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 164 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/second", FrameName: "shared", | |
| 165 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 166 | + | }) | |
| 167 | + | ||
| 168 | + | assert.Same(t, popupWV, first) | |
| 169 | + | assert.Nil(t, second, "CEF must block native creation after navigating the detached named context") | |
| 170 | + | assert.Equal(t, 1, hostCalls) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestPopupFloatingNoopenerDoesNotReuseDetachedNamedContext(t *testing.T) { | |
| 174 | + | ctx := context.Background() | |
| 175 | + | parentPaneID := entity.PaneID("floating") | |
| 176 | + | parentWV := mocks.NewMockWebView(t) | |
| 177 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 178 | + | firstWV := mocks.NewMockWebView(t) | |
| 179 | + | firstWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 180 | + | firstWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 181 | + | firstWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 182 | + | secondWV := mocks.NewMockWebView(t) | |
| 183 | + | secondWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 184 | + | secondWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 185 | + | secondWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 186 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 187 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once() | |
| 188 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once() | |
| 189 | + | c := &Coordinator{popups: newPopupManager()} | |
| 190 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 191 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 192 | + | hostCalls := 0 | |
| 193 | + | c.SetOnOpenBrowserWindow(func(_ context.Context, _ BrowserWindowInput) (BrowserWindowResult, error) { | |
| 194 | + | hostCalls++ | |
| 195 | + | return BrowserWindowResult{WindowID: "detached-window"}, nil | |
| 196 | + | }) | |
| 197 | + | req := port.PopupRequest{ | |
| 198 | + | Engine: dto.BrowserEngineCEF, FrameName: "shared", NoJavaScriptAccess: true, | |
| 199 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 200 | + | } | |
| 201 | + | req.TargetURI = "https://example.com/first" | |
| 202 | + | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, req) | |
| 203 | + | req.TargetURI = "https://example.com/second" | |
| 204 | + | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, req) | |
| 205 | + | ||
| 206 | + | assert.Same(t, firstWV, first) | |
| 207 | + | assert.Same(t, secondWV, second) | |
| 208 | + | assert.Equal(t, 2, hostCalls) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T) { | |
| 212 | + | parentWV := mocks.NewMockWebView(t) | |
| 213 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 214 | + | popupWV := mocks.NewMockWebView(t) | |
| 215 | + | popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 216 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 217 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 218 | + | popupWV.EXPECT().Destroy().Once() | |
| 219 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 220 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 221 | + | c := &Coordinator{popups: newPopupManager()} | |
| 222 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 223 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 224 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 225 | + | return BrowserWindowResult{}, errors.New("host failed") | |
| 226 | + | }) | |
| 227 | + | ||
| 228 | + | got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{ | |
| 229 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared", | |
| 230 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 231 | + | }) | |
| 232 | + | ||
| 233 | + | assert.Nil(t, got) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |
| 237 | + | ctx := context.Background() | |
| 238 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 239 | + | parentWV := mocks.NewMockWebView(t) | |
| 240 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 241 | + | ||
| 242 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 243 | + | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe() | |
| 244 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 245 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 246 | + | ||
| 247 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 248 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 249 | + | ||
| 250 | + | c := &Coordinator{ | |
| 251 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 252 | + | popups: newPopupManager(), | |
| 253 | + | } | |
| 254 | + | c.SetPopupConfig(factory, nil, nil) | |
| 255 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 256 | + | return nil | |
| 257 | + | }) | |
| 258 | + | ||
| 259 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 260 | + | TargetURI: "https://example.com/popup", | |
| 261 | + | FrameName: "auth-popup", | |
| 262 | + | IsUserGesture: true, | |
| 263 | + | NoJavaScriptAccess: true, | |
| 264 | + | }) | |
| 265 | + | ||
| 266 | + | require.Same(t, popupWV, created) | |
| 267 | + | require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) { | |
| 271 | + | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150)) | |
| 272 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 273 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 274 | + | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 275 | + | popupWV.EXPECT().URI().Return("").Once() | |
| 276 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once() | |
| 277 | + | ||
| 278 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 279 | + | require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID) | |
| 280 | + | require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID)) | |
| 281 | + | return nil | |
| 282 | + | }) | |
| 283 | + | ||
| 284 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 285 | + | TargetURI: "https://example.com/popup", | |
| 286 | + | FrameName: "auth-popup", | |
| 287 | + | IsUserGesture: true, | |
| 288 | + | NoJavaScriptAccess: true, | |
| 289 | + | }) | |
| 290 | + | ||
| 291 | + | require.Same(t, popupWV, created) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) { | |
| 295 | + | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151)) | |
| 296 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 297 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 298 | + | popupWV.EXPECT().Destroy().Once() | |
| 299 | + | ||
| 300 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 301 | + | require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID)) | |
| 302 | + | return assert.AnError | |
| 303 | + | }) | |
| 304 | + | ||
| 305 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 306 | + | TargetURI: "https://example.com/popup", | |
| 307 | + | FrameName: "auth-popup", | |
| 308 | + | IsUserGesture: true, | |
| 309 | + | NoJavaScriptAccess: true, | |
| 310 | + | }) | |
| 311 | + | ||
| 312 | + | require.Nil(t, created) | |
| 313 | + | require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane"))) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) { | |
| 317 | + | ctx := context.Background() | |
| 318 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 319 | + | parentWV := mocks.NewMockWebView(t) | |
| 320 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 321 | + | ||
| 322 | + | popupWV := mocks.NewMockWebView(t) | |
| 323 | + | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe() | |
| 324 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 325 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 326 | + | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 327 | + | popupWV.EXPECT().URI().Return("").Once() | |
| 328 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once() | |
| 329 | + | ||
| 330 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 331 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 332 | + | ||
| 333 | + | c := &Coordinator{ | |
| 334 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 335 | + | popups: newPopupManager(), | |
| 336 | + | } | |
| 337 | + | c.SetPopupConfig(factory, nil, nil) | |
| 338 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 339 | + | assert.Equal(t, "https://example.com/noopener", input.TargetURI) | |
| 340 | + | return nil | |
| 341 | + | }) | |
| 342 | + | ||
| 343 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 344 | + | TargetURI: "https://example.com/noopener", | |
| 345 | + | FrameName: "auth-popup", | |
| 346 | + | IsUserGesture: true, | |
| 347 | + | NoJavaScriptAccess: true, | |
| 348 | + | }) | |
| 349 | + | ||
| 350 | + | require.Same(t, popupWV, created) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) { | |
| 354 | + | ctx := context.Background() | |
| 355 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 356 | + | parentWV := mocks.NewMockWebView(t) | |
| 357 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 358 | + | ||
| 359 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 360 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once() | |
| 361 | + | ||
| 362 | + | insertCalls := 0 | |
| 363 | + | c := &Coordinator{ | |
| 364 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 365 | + | popups: newPopupManager(), | |
| 366 | + | } | |
| 367 | + | c.SetPopupConfig(factory, nil, nil) | |
| 368 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 369 | + | insertCalls++ | |
| 370 | + | return nil | |
| 371 | + | }) | |
| 372 | + | ||
| 373 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 374 | + | TargetURI: "https://example.com/edit", | |
| 375 | + | FrameName: "_blank", | |
| 376 | + | IsUserGesture: true, | |
| 377 | + | }) | |
| 378 | + | ||
| 379 | + | require.Nil(t, created) | |
| 380 | + | assert.Equal(t, 0, insertCalls) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) { | |
| 384 | + | ctx := context.Background() | |
| 385 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 386 | + | parentWV := mocks.NewMockWebView(t) | |
| 387 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 388 | + | ||
| 389 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 390 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once() | |
| 391 | + | ||
| 392 | + | insertCalls := 0 | |
| 393 | + | c := &Coordinator{ | |
| 394 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 395 | + | popups: newPopupManager(), | |
| 396 | + | } | |
| 397 | + | c.SetPopupConfig(factory, nil, nil) | |
| 398 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 399 | + | insertCalls++ | |
| 400 | + | return nil | |
| 401 | + | }) | |
| 402 | + | ||
| 403 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 404 | + | TargetURI: "https://example.com/edit", | |
| 405 | + | FrameName: "_blank", | |
| 406 | + | IsUserGesture: true, | |
| 407 | + | }) | |
| 408 | + | ||
| 409 | + | require.Nil(t, created) | |
| 410 | + | assert.Equal(t, 0, insertCalls) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) { | |
| 414 | + | ctx := context.Background() | |
| 415 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 416 | + | parentWV := mocks.NewMockWebView(t) | |
| 417 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 418 | + | ||
| 419 | + | newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 420 | + | newWV.EXPECT().ID().Return(port.WebViewID(301)).Maybe() | |
| 421 | + | newWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 422 | + | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 423 | + | newWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle-click").Return(nil).Maybe() | |
| 424 | + | ||
| 425 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 426 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Maybe() | |
| 427 | + | ||
| 428 | + | insertCalls := 0 | |
| 429 | + | c := &Coordinator{ | |
| 430 | + | webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, | |
| 431 | + | popups: newPopupManager(), | |
| 432 | + | } | |
| 433 | + | c.SetPopupConfig(factory, nil, nil) | |
| 434 | + | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 435 | + | insertCalls++ | |
| 436 | + | assert.Equal(t, parentPaneID, input.ParentPaneID) | |
| 437 | + | assert.Same(t, newWV, input.WebView) | |
| 438 | + | assert.Equal(t, entity.PopupBehaviorSplit, input.Behavior) | |
| 439 | + | return nil | |
| 440 | + | }) | |
| 441 | + | ||
| 442 | + | handled := c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle-click") | |
| 443 | + | assert.True(t, handled) | |
| 444 | + | assert.Equal(t, 1, insertCalls) | |
| 445 | + | decision, hasDecision := newWV.BrowsingContextHostDecision() | |
| 446 | + | require.True(t, hasDecision) | |
| 447 | + | assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) { | |
| 451 | + | ctx := context.Background() | |
| 452 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 453 | + | parentWV := mocks.NewMockWebView(t) | |
| 454 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 455 | + | parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once() | |
| 456 | + | ||
| 457 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 458 | + | popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe() | |
| 459 | + | ||
| 460 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 461 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 462 | + | ||
| 463 | + | insertCalls := 0 | |
| 464 | + | nativeCalls := 0 | |
| 465 | + | c := &Coordinator{ | |
| 466 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 467 | + | popups: newPopupManager(), | |
| 468 | + | } | |
| 469 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil) | |
| 470 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 471 | + | insertCalls++ | |
| 472 | + | return nil | |
| 473 | + | }) | |
| 474 | + | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 475 | + | nativeCalls++ | |
| 476 | + | assert.Equal(t, parentPaneID, input.ParentPaneID) | |
| 477 | + | assert.Equal(t, popupWV, input.PopupWebView) | |
| 478 | + | assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI) | |
| 479 | + | assert.True(t, input.ObserveOAuthAutoClose) | |
| 480 | + | return nil | |
| 481 | + | }) | |
| 482 | + | ||
| 483 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 484 | + | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 485 | + | FrameName: "oauth-popup", | |
| 486 | + | IsUserGesture: true, | |
| 487 | + | }) | |
| 488 | + | ||
| 489 | + | require.Equal(t, popupWV, created) | |
| 490 | + | assert.Equal(t, 0, insertCalls) | |
| 491 | + | assert.Equal(t, 1, nativeCalls) | |
| 492 | + | assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) { | |
| 496 | + | ctx := context.Background() | |
| 497 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 498 | + | parentWV := mocks.NewMockWebView(t) | |
| 499 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 500 | + | ||
| 501 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 502 | + | popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe() | |
| 503 | + | ||
| 504 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 505 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 506 | + | ||
| 507 | + | insertCalls := 0 | |
| 508 | + | nativeCalls := 0 | |
| 509 | + | c := &Coordinator{ | |
| 510 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 511 | + | popups: newPopupManager(), | |
| 512 | + | } | |
| 513 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil) | |
| 514 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 515 | + | insertCalls++ | |
| 516 | + | return nil | |
| 517 | + | }) | |
| 518 | + | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 519 | + | nativeCalls++ | |
| 520 | + | assert.False(t, input.ObserveOAuthAutoClose) | |
| 521 | + | return nil | |
| 522 | + | }) | |
| 523 | + | ||
| 524 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 525 | + | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 526 | + | FrameName: "oauth-popup", | |
| 527 | + | IsUserGesture: true, | |
| 528 | + | }) | |
| 529 | + | ||
| 530 | + | require.Equal(t, popupWV, created) | |
| 531 | + | assert.Equal(t, 1, nativeCalls) | |
| 532 | + | assert.Equal(t, 0, insertCalls) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) { | |
| 536 | + | ctx := context.Background() | |
| 537 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 538 | + | popupWV.EXPECT().ID().Return(port.WebViewID(701)).Once() | |
| 539 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 540 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 541 | + | ||
| 542 | + | pm := newPopupManager() | |
| 543 | + | pm.setConfig(factory, nil, nil) | |
| 544 | + | browserCalls := 0 | |
| 545 | + | pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 546 | + | browserCalls++ | |
| 547 | + | assert.Same(t, popupWV, input.PopupWebView) | |
| 548 | + | assert.True(t, input.Ready) | |
| 549 | + | return BrowserWindowResult{WindowID: "fallback-window"}, nil | |
| 550 | + | }) | |
| 551 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |
| 552 | + | assert.True(t, input.AllowBrowserWindowFallback) | |
| 553 | + | assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView)) | |
| 554 | + | assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView)) | |
| 555 | + | return nil | |
| 556 | + | }) | |
| 557 | + | ||
| 558 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 559 | + | TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | |
| 560 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 561 | + | require.Same(t, popupWV, got) | |
| 562 | + | assert.Equal(t, 1, browserCalls) | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | func TestPopupNativeAbortFallsBackForOpenerCapableVisualPopup(t *testing.T) { | |
| 566 | + | ctx := context.Background() | |
| 567 | + | popupWV := &popupOpenerWebViewStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}} | |
| 568 | + | popupWV.EXPECT().ID().Return(port.WebViewID(703)).Once() | |
| 569 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 570 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 571 | + | ||
| 572 | + | pm := newPopupManager() | |
| 573 | + | pm.setConfig(factory, nil, nil) | |
| 574 | + | browserCalls := 0 | |
| 575 | + | pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 576 | + | browserCalls++ | |
| 577 | + | assert.Same(t, popupWV, input.PopupWebView) | |
| 578 | + | return BrowserWindowResult{WindowID: "fallback-window"}, nil | |
| 579 | + | }) | |
| 580 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |
| 581 | + | assert.True(t, input.AllowBrowserWindowFallback) | |
| 582 | + | assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView)) | |
| 583 | + | return nil | |
| 584 | + | }) | |
| 585 | + | ||
| 586 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 587 | + | TargetURI: "https://example.com/visual", NoJavaScriptAccess: false, | |
| 588 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 589 | + | ||
| 590 | + | require.Same(t, popupWV, got) | |
| 591 | + | assert.Equal(t, 1, browserCalls) | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | |
| 595 | + | for _, tc := range []struct { | |
| 596 | + | name string | |
| 597 | + | target string | |
| 598 | + | decision dto.HostDecision | |
| 599 | + | }{ | |
| 600 | + | {name: "auth", target: "https://accounts.google.com/o/oauth2/auth", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}}, | |
| 601 | + | {name: "required opener", target: "https://example.com/opener", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true}}, | |
| 602 | + | } { | |
| 603 | + | t.Run(tc.name, func(t *testing.T) { | |
| 604 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 605 | + | popupWV.EXPECT().Destroy().Once() | |
| 606 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 607 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 608 | + | pm := newPopupManager() | |
| 609 | + | pm.setConfig(factory, nil, nil) | |
| 610 | + | pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 611 | + | t.Fatal("auth/opener-required popup must not degrade to browser fallback") | |
| 612 | + | return BrowserWindowResult{}, nil | |
| 613 | + | }) | |
| 614 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |
| 615 | + | assert.False(t, input.AllowBrowserWindowFallback) | |
| 616 | + | if !input.OnNativeHostAbort(ctx, input.PopupWebView) { | |
| 617 | + | input.PopupWebView.Destroy() | |
| 618 | + | } | |
| 619 | + | return nil | |
| 620 | + | }) | |
| 621 | + | ||
| 622 | + | got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 623 | + | TargetURI: tc.target, NoJavaScriptAccess: true, | |
| 624 | + | }, tc.decision) | |
| 625 | + | require.Same(t, popupWV, got) | |
| 626 | + | }) | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |
| 631 | + | var output bytes.Buffer | |
| 632 | + | logger := zerolog.New(&output) | |
| 633 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 634 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 635 | + | popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once() | |
| 636 | + | popupWV.EXPECT().Destroy().Once() | |
| 637 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 638 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 639 | + | pm := newPopupManager() | |
| 640 | + | pm.setConfig(factory, nil, nil) | |
| 641 | + | pm.setSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 642 | + | pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 643 | + | return BrowserWindowResult{}, errors.New("fallback failed") | |
| 644 | + | }) | |
| 645 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |
| 646 | + | if !input.OnNativeHostAbort(ctx, input.PopupWebView) { | |
| 647 | + | input.PopupWebView.Destroy() | |
| 648 | + | } | |
| 649 | + | return nil | |
| 650 | + | }) | |
| 651 | + | ||
| 652 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 653 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | |
| 654 | + | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 655 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 656 | + | require.Same(t, popupWV, got) | |
| 657 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 658 | + | assert.Equal(t, "cef", record["engine"]) | |
| 659 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 660 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 661 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 662 | + | assert.Equal(t, "fallback-failed", record["reason_code"]) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) { | |
| 666 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 667 | + | popupWV.EXPECT().Destroy().Once() | |
| 668 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 669 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 670 | + | pm := newPopupManager() | |
| 671 | + | pm.setConfig(factory, nil, nil) | |
| 672 | + | pm.setOnOpenNativePopup(func(context.Context, NativePopupInput) error { return errors.New("setup failed") }) | |
| 673 | + | ||
| 674 | + | got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 675 | + | TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | |
| 676 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 677 | + | assert.Nil(t, got) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) { | |
| 681 | + | ctx := context.Background() | |
| 682 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 683 | + | parentWV := mocks.NewMockWebView(t) | |
| 684 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 685 | + | ||
| 686 | + | firstWV := mocks.NewMockWebView(t) | |
| 687 | + | firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe() | |
| 688 | + | firstWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 689 | + | firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 690 | + | firstWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 691 | + | firstWV.EXPECT().URI().Return("").Maybe() | |
| 692 | + | firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe() | |
| 693 | + | ||
| 694 | + | secondWV := mocks.NewMockWebView(t) | |
| 695 | + | secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe() | |
| 696 | + | secondWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 697 | + | secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 698 | + | secondWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 699 | + | secondWV.EXPECT().URI().Return("").Maybe() | |
| 700 | + | secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe() | |
| 701 | + | ||
| 702 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 703 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once() | |
| 704 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once() | |
| 705 | + | ||
| 706 | + | insertCalls := 0 | |
| 707 | + | c := &Coordinator{ | |
| 708 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 709 | + | popups: newPopupManager(), | |
| 710 | + | } | |
| 711 | + | c.SetPopupConfig(factory, nil, nil) | |
| 712 | + | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 713 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 714 | + | insertCalls++ | |
| 715 | + | return nil | |
| 716 | + | }) | |
| 717 | + | ||
| 718 | + | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 719 | + | TargetURI: "https://example.com/first", | |
| 720 | + | FrameName: "shared-pane", | |
| 721 | + | IsUserGesture: true, | |
| 722 | + | NoJavaScriptAccess: true, | |
| 723 | + | }) | |
| 724 | + | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 725 | + | TargetURI: "https://example.com/second", | |
| 726 | + | FrameName: "shared-pane", | |
| 727 | + | IsUserGesture: true, | |
| 728 | + | NoJavaScriptAccess: true, | |
| 729 | + | }) | |
| 730 | + | ||
| 731 | + | require.Same(t, firstWV, first) | |
| 732 | + | require.Same(t, secondWV, second) | |
| 733 | + | assert.Equal(t, 2, insertCalls) | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) { | |
| 737 | + | ctx := context.Background() | |
| 738 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 739 | + | parentWV := mocks.NewMockWebView(t) | |
| 740 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 741 | + | ||
| 742 | + | popupWV := mocks.NewMockWebView(t) | |
| 743 | + | popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 744 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 745 | + | popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 746 | + | popupWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 747 | + | currentURI := "" | |
| 748 | + | popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe() | |
| 749 | + | popupWV.EXPECT().IsDestroyed().Return(false).Maybe() | |
| 750 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error { | |
| 751 | + | currentURI = "https://example.com/first" | |
| 752 | + | return nil | |
| 753 | + | }).Once() | |
| 754 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error { | |
| 755 | + | currentURI = "https://example.com/second" | |
| 756 | + | return nil | |
| 757 | + | }).Once() | |
| 758 | + | ||
| 759 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 760 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 761 | + | ||
| 762 | + | insertCalls := 0 | |
| 763 | + | c := &Coordinator{ | |
| 764 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 765 | + | popups: newPopupManager(), | |
| 766 | + | } | |
| 767 | + | c.SetPopupConfig(factory, nil, nil) | |
| 768 | + | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 769 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 770 | + | insertCalls++ | |
| 771 | + | return nil | |
| 772 | + | }) | |
| 773 | + | ||
| 774 | + | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 775 | + | Engine: dto.BrowserEngineWebKit, | |
| 776 | + | TargetURI: "https://example.com/first", | |
| 777 | + | FrameName: "shared-pane", | |
| 778 | + | IsUserGesture: true, | |
| 779 | + | }) | |
| 780 | + | require.Same(t, popupWV, first) | |
| 781 | + | ||
| 782 | + | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 783 | + | Engine: dto.BrowserEngineWebKit, | |
| 784 | + | TargetURI: "https://example.com/second", | |
| 785 | + | FrameName: "shared-pane", | |
| 786 | + | IsUserGesture: true, | |
| 787 | + | }) | |
| 788 | + | require.Nil(t, second, "WebKit must block native creation after navigating the named context") | |
| 789 | + | ||
| 790 | + | assert.Equal(t, 1, insertCalls) | |
| 791 | + | assert.Equal(t, "https://example.com/second", currentURI) | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t *testing.T) { | |
| 795 | + | ctx := context.Background() | |
| 796 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 797 | + | stalePaneID := entity.PaneID("stale-pane") | |
| 798 | + | parentWV := mocks.NewMockWebView(t) | |
| 799 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 800 | + | ||
| 801 | + | staleWV := mocks.NewMockWebView(t) | |
| 802 | + | staleWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 803 | + | staleWV.EXPECT().ID().Return(port.WebViewID(202)).Once() | |
| 804 | + | staleWV.EXPECT().IsDestroyed().Return(true).Once() | |
| 805 | + | ||
| 806 | + | newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 807 | + | newWV.EXPECT().ID().Return(port.WebViewID(303)).Maybe() | |
| 808 | + | newWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 809 | + | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 810 | + | ||
| 811 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 812 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Once() | |
| 813 | + | ||
| 814 | + | insertCalls := 0 | |
| 815 | + | c := &Coordinator{ | |
| 816 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 817 | + | stalePaneID: staleWV, | |
| 818 | + | }, | |
| 819 | + | popups: newPopupManager(), | |
| 820 | + | } | |
| 821 | + | c.SetPopupConfig(factory, nil, nil) | |
| 822 | + | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 823 | + | c.popups.namedContexts.Register("window-1", "window-1", "shared-pane", stalePaneID, port.WebViewID(202)) | |
| 824 | + | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 825 | + | insertCalls++ | |
| 826 | + | return nil | |
| 827 | + | }) | |
| 828 | + | ||
| 829 | + | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 830 | + | TargetURI: "https://example.com/replacement", | |
| 831 | + | FrameName: "shared-pane", | |
| 832 | + | IsUserGesture: true, | |
| 833 | + | }) | |
| 834 | + | ||
| 835 | + | require.Same(t, newWV, created) | |
| 836 | + | assert.Equal(t, 1, insertCalls) | |
| 837 | + | decision, hasDecision := newWV.BrowsingContextHostDecision() | |
| 838 | + | require.True(t, hasDecision) | |
| 839 | + | assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind) | |
| 840 | + | assert.Empty(t, decision.ReuseContextName) | |
| 841 | + | assert.Equal(t, "shared-pane", decision.BrowsingContextName) | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | // --------------------------------------------------------------------------- | |
| 845 | + | // trackOAuthPopup / capturePopupOAuthState | |
| 846 | + | // --------------------------------------------------------------------------- | |
A internal/ui/coordinator/content/popup_deferred.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func (pm *popupManager) awaitPopupFeatures( | |
| 14 | + | ctx context.Context, | |
| 15 | + | hooks popupCoordinatorHooks, | |
| 16 | + | parentPaneID entity.PaneID, | |
| 17 | + | parentWebViewID port.WebViewID, | |
| 18 | + | parentWebView port.WebView, | |
| 19 | + | parentURIAtOpen string, | |
| 20 | + | req port.PopupRequest, | |
| 21 | + | decision dto.HostDecision, | |
| 22 | + | ) port.WebView { | |
| 23 | + | log := logging.FromContext(ctx) | |
| 24 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 25 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 26 | + | popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, false) | |
| 27 | + | if err != nil { | |
| 28 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | resolver, resolverOK := popupWV.(port.PopupFeatureResolver) | |
| 32 | + | lifecycle, lifecycleOK := popupWV.(port.PopupLifecycleCapable) | |
| 33 | + | if !resolverOK || !lifecycleOK || pm.onStagePopup == nil { | |
| 34 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 35 | + | popupWV.Destroy() | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | staging, err := pm.onStagePopup(ctx, StagePopupInput{PopupWebView: popupWV}) | |
| 39 | + | if err != nil || staging == nil { | |
| 40 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 41 | + | popupWV.Destroy() | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 45 | + | paneID, _ := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI) | |
| 46 | + | ownerWindowID := "" | |
| 47 | + | if pm.windowIDForPane != nil { | |
| 48 | + | ownerWindowID, _ = pm.windowIDForPane(parentPaneID) | |
| 49 | + | } | |
| 50 | + | pending := &deferredPendingPopup{ | |
| 51 | + | PendingPopup: &PendingPopup{ | |
| 52 | + | PaneID: paneID, WebView: popupWV, ParentPaneID: parentPaneID, | |
| 53 | + | ParentWebViewID: parentWebViewID, TargetURI: req.TargetURI, FrameName: req.FrameName, | |
| 54 | + | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | |
| 55 | + | }, | |
| 56 | + | Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID, | |
| 57 | + | ParentWebView: parentWebView, | |
| 58 | + | } | |
| 59 | + | pm.storeDeferredPopup(popupWV.ID(), pending) | |
| 60 | + | callbackCtx := logging.WithContext(context.Background(), *log) | |
| 61 | + | lifecycle.SetOnReadyToShow(func() { | |
| 62 | + | pm.resolvePendingPopupFeatures(callbackCtx, hooks, popupWV.ID(), parentURIAtOpen, resolver) | |
| 63 | + | }) | |
| 64 | + | lifecycle.SetOnClose(func() { | |
| 65 | + | pm.failPendingPopup(callbackCtx, popupWV.ID(), dto.BrowsingContextFailureHostFailed, nil) | |
| 66 | + | }) | |
| 67 | + | return popupWV | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (pm *popupManager) cleanupDeferredPending(pending *deferredPendingPopup) { | |
| 71 | + | if pending == nil { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | pending.cleanupOnce.Do(func() { | |
| 75 | + | if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok { | |
| 76 | + | lifecycle.SetOnReadyToShow(nil) | |
| 77 | + | lifecycle.SetOnClose(nil) | |
| 78 | + | } | |
| 79 | + | if pending.StagingHost != nil { | |
| 80 | + | pending.StagingHost.Destroy() | |
| 81 | + | } | |
| 82 | + | if pending.WebView != nil { | |
| 83 | + | pending.WebView.Destroy() | |
| 84 | + | } | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (pm *popupManager) cancelDeferredPopupsMatching(match func(*deferredPendingPopup) bool) { | |
| 89 | + | if pm == nil || match == nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | pm.mu.Lock() | |
| 93 | + | canceled := make([]*deferredPendingPopup, 0) | |
| 94 | + | for popupID, pending := range pm.deferredPopups { | |
| 95 | + | if pending == nil || !match(pending) { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | delete(pm.deferredPopups, popupID) | |
| 99 | + | delete(pm.pendingPopups, popupID) | |
| 100 | + | canceled = append(canceled, pending) | |
| 101 | + | } | |
| 102 | + | pm.mu.Unlock() | |
| 103 | + | for _, pending := range canceled { | |
| 104 | + | pm.cleanupDeferredPending(pending) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (pm *popupManager) cancelDeferredPopupsForParent(parentPaneID entity.PaneID) { | |
| 109 | + | pm.cancelDeferredPopupsMatching(func(pending *deferredPendingPopup) bool { | |
| 110 | + | return pending.ParentPaneID == parentPaneID | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (pm *popupManager) cancelDeferredPopupsForWindow(windowID string) { | |
| 115 | + | if pm == nil || windowID == "" { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | pm.cancelDeferredPopupsMatching(func(pending *deferredPendingPopup) bool { | |
| 119 | + | return pending.OwnerWindowID == windowID | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (pm *popupManager) cancelAllDeferredPopups() { | |
| 124 | + | pm.cancelDeferredPopupsMatching(func(*deferredPendingPopup) bool { return true }) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (pm *popupManager) failPendingPopup( | |
| 128 | + | ctx context.Context, | |
| 129 | + | popupID port.WebViewID, | |
| 130 | + | code dto.BrowsingContextFailureCode, | |
| 131 | + | err error, | |
| 132 | + | ) { | |
| 133 | + | pending, ok := pm.takeDeferredPopup(popupID) | |
| 134 | + | if !ok || pending == nil { | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 138 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 139 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, code, err) | |
| 140 | + | pm.cleanupDeferredPending(pending) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (pm *popupManager) resolvePendingPopupFeatures( | |
| 144 | + | ctx context.Context, | |
| 145 | + | hooks popupCoordinatorHooks, | |
| 146 | + | popupID port.WebViewID, | |
| 147 | + | parentURIAtOpen string, | |
| 148 | + | resolver port.PopupFeatureResolver, | |
| 149 | + | ) { | |
| 150 | + | pending, ok := pm.takeDeferredPopup(popupID) | |
| 151 | + | if !ok || pending == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | resolved := resolver.ResolvePopupFeatures() | |
| 155 | + | if resolved.State == dto.PopupFeaturesUnknown { | |
| 156 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 157 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 158 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureFeatureResolution, nil) | |
| 159 | + | pm.cleanupDeferredPending(pending) | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | if err := pending.StagingHost.Detach(); err != nil { | |
| 163 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 164 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 165 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureStagingDetach, err) | |
| 166 | + | pm.cleanupDeferredPending(pending) | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | request := pending.Request | |
| 171 | + | request.PopupFeatures = resolved | |
| 172 | + | normalized := buildPopupBrowsingContextRequest(request) | |
| 173 | + | normalized.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 174 | + | namedContextExists := false | |
| 175 | + | if !request.NoJavaScriptAccess { | |
| 176 | + | _, namedContextExists = pm.lookupReusableNamedPopup(pending.ParentPaneID, request.FrameName, hooks) | |
| 177 | + | } | |
| 178 | + | decision := pm.policy.Decide(normalized, namedContextExists) | |
| 179 | + | logBrowsingContextDecision(*logging.FromContext(ctx), normalized, decision) | |
| 180 | + | pm.setBrowsingContextDecision(pending.WebView, decision) | |
| 181 | + | ||
| 182 | + | transferred := false | |
| 183 | + | switch decision.Kind { | |
| 184 | + | case dto.HostDecisionNavigateSource: | |
| 185 | + | if pending.ParentWebView == nil { | |
| 186 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 187 | + | break | |
| 188 | + | } | |
| 189 | + | if err := pending.ParentWebView.LoadURI(ctx, request.TargetURI); err != nil { | |
| 190 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 191 | + | } | |
| 192 | + | case dto.HostDecisionCreateBrowserWindow: | |
| 193 | + | transferred = pm.openExistingPopupInBrowserWindow( | |
| 194 | + | ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, | |
| 195 | + | pending.WebView, request, decision, true, | |
| 196 | + | ) | |
| 197 | + | case dto.HostDecisionCreateNativePopup: | |
| 198 | + | transferred = pm.openExistingPopupInNativePopup( | |
| 199 | + | ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, | |
| 200 | + | parentURIAtOpen, pending.WebView, request, decision, | |
| 201 | + | ) | |
| 202 | + | case dto.HostDecisionReuseNamedPane: | |
| 203 | + | // Reuse transfers no ownership: the existing context is navigated while | |
| 204 | + | // the newly staged WebView remains ours and must be discarded below. | |
| 205 | + | if _, ok := pm.reuseNamedPopup(ctx, hooks, pending.ParentPaneID, request.FrameName, request.TargetURI); !ok { | |
| 206 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 207 | + | } | |
| 208 | + | default: | |
| 209 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureFeatureResolution, nil) | |
| 210 | + | } | |
| 211 | + | if !transferred { | |
| 212 | + | pm.cleanupDeferredPending(pending) | |
| 213 | + | } | |
| 214 | + | } | |
A internal/ui/coordinator/content/popup_hosting.go +247 -0
| @@ -0,0 +1,247 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // openBrowserWindow transfers a related WebView to a complete browser window. | |
| 15 | + | // The popup manager retains ownership until the host callback succeeds. | |
| 16 | + | func (pm *popupManager) openBrowserWindow( | |
| 17 | + | ctx context.Context, | |
| 18 | + | hooks popupCoordinatorHooks, | |
| 19 | + | parentPaneID entity.PaneID, | |
| 20 | + | parentWebViewID port.WebViewID, | |
| 21 | + | req port.PopupRequest, | |
| 22 | + | decision dto.HostDecision, | |
| 23 | + | ready bool, | |
| 24 | + | ) port.WebView { | |
| 25 | + | log := logging.FromContext(ctx) | |
| 26 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 27 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 28 | + | if pm.onOpenBrowserWindow == nil { | |
| 29 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, true) | |
| 33 | + | if err != nil { | |
| 34 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 38 | + | if !pm.adoptPopupInBrowserWindow( | |
| 39 | + | ctx, hooks, parentPaneID, parentWebViewID, popupWV, req, decision, ready, | |
| 40 | + | dto.BrowsingContextFailureHostUnavailable, dto.BrowsingContextFailureHostFailed, | |
| 41 | + | "browser window host returned an empty window ID", | |
| 42 | + | ) { | |
| 43 | + | popupWV.Destroy() | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 47 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 48 | + | } | |
| 49 | + | return popupWV | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // openExistingPopupInBrowserWindow attempts to transfer an already-created | |
| 53 | + | // WebView. On failure ownership remains with the caller. | |
| 54 | + | func (pm *popupManager) openExistingPopupInBrowserWindow( | |
| 55 | + | ctx context.Context, | |
| 56 | + | hooks popupCoordinatorHooks, | |
| 57 | + | parentPaneID entity.PaneID, | |
| 58 | + | parentWebViewID port.WebViewID, | |
| 59 | + | popupWV port.WebView, | |
| 60 | + | req port.PopupRequest, | |
| 61 | + | decision dto.HostDecision, | |
| 62 | + | ready bool, | |
| 63 | + | ) bool { | |
| 64 | + | return pm.adoptPopupInBrowserWindow( | |
| 65 | + | ctx, hooks, parentPaneID, parentWebViewID, popupWV, req, decision, ready, | |
| 66 | + | dto.BrowsingContextFailureFallback, dto.BrowsingContextFailureFallback, | |
| 67 | + | "browser window fallback returned an empty window ID", | |
| 68 | + | ) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (pm *popupManager) adoptPopupInBrowserWindow( | |
| 72 | + | ctx context.Context, | |
| 73 | + | hooks popupCoordinatorHooks, | |
| 74 | + | parentPaneID entity.PaneID, | |
| 75 | + | parentWebViewID port.WebViewID, | |
| 76 | + | popupWV port.WebView, | |
| 77 | + | req port.PopupRequest, | |
| 78 | + | decision dto.HostDecision, | |
| 79 | + | ready bool, | |
| 80 | + | unavailableCode dto.BrowsingContextFailureCode, | |
| 81 | + | failureCode dto.BrowsingContextFailureCode, | |
| 82 | + | emptyWindowError string, | |
| 83 | + | ) bool { | |
| 84 | + | log := logging.FromContext(ctx) | |
| 85 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 86 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 87 | + | if popupWV == nil || pm.onOpenBrowserWindow == nil { | |
| 88 | + | logBrowsingContextFailure(*log, normalized, decision, unavailableCode, nil) | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI) | |
| 93 | + | if hooks.setupWebViewCallbacks != nil { | |
| 94 | + | hooks.setupWebViewCallbacks(ctx, paneID, popupWV) | |
| 95 | + | } | |
| 96 | + | result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{ | |
| 97 | + | ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane, | |
| 98 | + | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready, | |
| 99 | + | }) | |
| 100 | + | if err != nil || result.WindowID == "" { | |
| 101 | + | if err == nil { | |
| 102 | + | err = errors.New(emptyWindowError) | |
| 103 | + | } | |
| 104 | + | logBrowsingContextFailure(*log, normalized, decision, failureCode, err) | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | if !req.NoJavaScriptAccess { | |
| 108 | + | pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID) | |
| 109 | + | } | |
| 110 | + | return true | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (pm *popupManager) navigatePopupSource( | |
| 114 | + | ctx context.Context, | |
| 115 | + | parentID port.WebViewID, | |
| 116 | + | parentWV port.WebView, | |
| 117 | + | req port.PopupRequest, | |
| 118 | + | request dto.NewBrowsingContextRequest, | |
| 119 | + | decision dto.HostDecision, | |
| 120 | + | ) { | |
| 121 | + | log := logging.FromContext(ctx) | |
| 122 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 123 | + | if err != nil { | |
| 124 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 128 | + | popupWV.Destroy() | |
| 129 | + | if err := parentWV.LoadURI(ctx, req.TargetURI); err != nil { | |
| 130 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (*popupManager) navigateSource(ctx context.Context, parentWV port.WebView, uri string) bool { | |
| 135 | + | if err := parentWV.LoadURI(ctx, uri); err != nil { | |
| 136 | + | logging.FromContext(ctx).Error().Err(err). | |
| 137 | + | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 138 | + | Msg("failed to load URI in source floating pane") | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | return true | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func popupSupportsOpenerBridge(wv port.WebView) bool { | |
| 145 | + | _, ok := wv.(port.PopupOpenerCapable) | |
| 146 | + | return ok | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (pm *popupManager) openNativePopup( | |
| 150 | + | ctx context.Context, | |
| 151 | + | hooks popupCoordinatorHooks, | |
| 152 | + | parentPaneID entity.PaneID, | |
| 153 | + | parentID port.WebViewID, | |
| 154 | + | parentURIAtOpen string, | |
| 155 | + | req port.PopupRequest, | |
| 156 | + | decision dto.HostDecision, | |
| 157 | + | ) port.WebView { | |
| 158 | + | log := logging.FromContext(ctx) | |
| 159 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 160 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 161 | + | if pm.onOpenNativePopup == nil { | |
| 162 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 166 | + | if err != nil { | |
| 167 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 171 | + | if !pm.adoptPopupInNativePopup( | |
| 172 | + | ctx, hooks, parentPaneID, parentID, parentURIAtOpen, popupWV, req, decision, | |
| 173 | + | dto.BrowsingContextFailureNativeArm, | |
| 174 | + | ) { | |
| 175 | + | popupWV.Destroy() | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | return popupWV | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // openExistingPopupInNativePopup attempts to transfer an already-created | |
| 182 | + | // WebView. On failure ownership remains with the caller. | |
| 183 | + | func (pm *popupManager) openExistingPopupInNativePopup( | |
| 184 | + | ctx context.Context, | |
| 185 | + | hooks popupCoordinatorHooks, | |
| 186 | + | parentPaneID entity.PaneID, | |
| 187 | + | parentWebViewID port.WebViewID, | |
| 188 | + | parentURIAtOpen string, | |
| 189 | + | popupWV port.WebView, | |
| 190 | + | req port.PopupRequest, | |
| 191 | + | decision dto.HostDecision, | |
| 192 | + | ) bool { | |
| 193 | + | return pm.adoptPopupInNativePopup( | |
| 194 | + | ctx, hooks, parentPaneID, parentWebViewID, parentURIAtOpen, popupWV, req, decision, | |
| 195 | + | dto.BrowsingContextFailureHostFailed, | |
| 196 | + | ) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (pm *popupManager) adoptPopupInNativePopup( | |
| 200 | + | ctx context.Context, | |
| 201 | + | hooks popupCoordinatorHooks, | |
| 202 | + | parentPaneID entity.PaneID, | |
| 203 | + | parentWebViewID port.WebViewID, | |
| 204 | + | parentURIAtOpen string, | |
| 205 | + | popupWV port.WebView, | |
| 206 | + | req port.PopupRequest, | |
| 207 | + | decision dto.HostDecision, | |
| 208 | + | failureCode dto.BrowsingContextFailureCode, | |
| 209 | + | ) bool { | |
| 210 | + | log := logging.FromContext(ctx) | |
| 211 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 212 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 213 | + | if popupWV == nil || pm.onOpenNativePopup == nil { | |
| 214 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 215 | + | return false | |
| 216 | + | } | |
| 217 | + | cfg := pm.currentPopupConfig() | |
| 218 | + | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && | |
| 219 | + | (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV)) | |
| 220 | + | var abortOnce sync.Once | |
| 221 | + | abortResult := false | |
| 222 | + | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |
| 223 | + | abortOnce.Do(func() { | |
| 224 | + | if !allowFallback { | |
| 225 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil) | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | abortResult = pm.openExistingPopupInBrowserWindow( | |
| 229 | + | abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true, | |
| 230 | + | ) | |
| 231 | + | }) | |
| 232 | + | return abortResult | |
| 233 | + | } | |
| 234 | + | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |
| 235 | + | ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen, | |
| 236 | + | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, | |
| 237 | + | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |
| 238 | + | AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort, | |
| 239 | + | }); err != nil { | |
| 240 | + | logBrowsingContextFailure(*log, normalized, decision, failureCode, err) | |
| 241 | + | return false | |
| 242 | + | } | |
| 243 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 244 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 245 | + | } | |
| 246 | + | return true | |
| 247 | + | } | |
A internal/ui/coordinator/content/popup_lifecycle_test.go +684 -0
| @@ -0,0 +1,684 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 17 | + | "github.com/bnema/dumber/internal/application/port" | |
| 18 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 19 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 20 | + | "github.com/bnema/dumber/internal/logging" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // --------------------------------------------------------------------------- | |
| 24 | + | // DetectPopupType | |
| 25 | + | // --------------------------------------------------------------------------- | |
| 26 | + | ||
| 27 | + | func TestTrackOAuthPopup_StoresState(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 29 | + | ||
| 30 | + | c := &Coordinator{ | |
| 31 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 32 | + | popups: newPopupManager(), | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | popupID := port.WebViewID(1) | |
| 36 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 37 | + | ||
| 38 | + | c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login") | |
| 39 | + | ||
| 40 | + | c.popups.mu.RLock() | |
| 41 | + | state, ok := c.popups.popupOAuth[popupID] | |
| 42 | + | c.popups.mu.RUnlock() | |
| 43 | + | ||
| 44 | + | require.True(t, ok, "oauth state should be tracked after trackOAuthPopup") | |
| 45 | + | require.NotNil(t, state) | |
| 46 | + | assert.Equal(t, parentPaneID, state.ParentPaneID) | |
| 47 | + | assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen) | |
| 48 | + | assert.False(t, state.Seen, "Seen should be false before capturing callback URI") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 53 | + | ||
| 54 | + | c := &Coordinator{ | |
| 55 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 56 | + | popups: newPopupManager(), | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | popupID := port.WebViewID(2) | |
| 60 | + | c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login") | |
| 61 | + | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123") | |
| 62 | + | ||
| 63 | + | c.popups.mu.RLock() | |
| 64 | + | state := c.popups.popupOAuth[popupID] | |
| 65 | + | c.popups.mu.RUnlock() | |
| 66 | + | ||
| 67 | + | require.NotNil(t, state) | |
| 68 | + | assert.True(t, state.Seen) | |
| 69 | + | assert.True(t, state.Success) | |
| 70 | + | assert.False(t, state.Error) | |
| 71 | + | assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) { | |
| 75 | + | t.Parallel() | |
| 76 | + | ||
| 77 | + | c := &Coordinator{ | |
| 78 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 79 | + | popups: newPopupManager(), | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | popupID := port.WebViewID(3) | |
| 83 | + | c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login") | |
| 84 | + | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied") | |
| 85 | + | ||
| 86 | + | c.popups.mu.RLock() | |
| 87 | + | state := c.popups.popupOAuth[popupID] | |
| 88 | + | c.popups.mu.RUnlock() | |
| 89 | + | ||
| 90 | + | require.NotNil(t, state) | |
| 91 | + | assert.True(t, state.Seen) | |
| 92 | + | assert.False(t, state.Success) | |
| 93 | + | assert.True(t, state.Error) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) { | |
| 97 | + | t.Parallel() | |
| 98 | + | ||
| 99 | + | c := &Coordinator{ | |
| 100 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 101 | + | popups: newPopupManager(), | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Should not panic when no tracking entry exists. | |
| 105 | + | c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x") | |
| 106 | + | ||
| 107 | + | c.popups.mu.RLock() | |
| 108 | + | _, ok := c.popups.popupOAuth[port.WebViewID(999)] | |
| 109 | + | c.popups.mu.RUnlock() | |
| 110 | + | ||
| 111 | + | assert.False(t, ok) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // --------------------------------------------------------------------------- | |
| 115 | + | // SetPopupConfig | |
| 116 | + | // --------------------------------------------------------------------------- | |
| 117 | + | ||
| 118 | + | func TestSetPopupConfig_SetsFields(t *testing.T) { | |
| 119 | + | t.Parallel() | |
| 120 | + | ||
| 121 | + | c := &Coordinator{} | |
| 122 | + | ||
| 123 | + | factory := &stubFactory{} | |
| 124 | + | cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit} | |
| 125 | + | genID := func() string { return "test-id" } | |
| 126 | + | ||
| 127 | + | c.SetPopupConfig(factory, cfg, genID) | |
| 128 | + | ||
| 129 | + | assert.Equal(t, factory, c.popups.factory) | |
| 130 | + | assert.True(t, c.popups.hasPopupConfig) | |
| 131 | + | assert.Equal(t, *cfg, c.popups.popupConfig) | |
| 132 | + | cfg.Behavior = entity.PopupBehaviorWindowed | |
| 133 | + | assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior) | |
| 134 | + | assert.NotNil(t, c.popups.generatePaneID) | |
| 135 | + | assert.Equal(t, "test-id", c.popups.generatePaneID()) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestSetPopupConfig_NilConfigAllowed(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | ||
| 141 | + | c := &Coordinator{} | |
| 142 | + | c.SetPopupConfig(nil, nil, nil) | |
| 143 | + | ||
| 144 | + | assert.Nil(t, c.popups.factory) | |
| 145 | + | assert.False(t, c.popups.hasPopupConfig) | |
| 146 | + | assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig) | |
| 147 | + | assert.Nil(t, c.popups.generatePaneID) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) { | |
| 151 | + | t.Parallel() | |
| 152 | + | ||
| 153 | + | c := &Coordinator{} | |
| 154 | + | initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false} | |
| 155 | + | c.SetPopupConfig(nil, initial, nil) | |
| 156 | + | ||
| 157 | + | c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true}) | |
| 158 | + | ||
| 159 | + | assert.True(t, c.popups.hasPopupConfig) | |
| 160 | + | assert.False(t, c.popups.popupConfig.OpenInNewPane) | |
| 161 | + | assert.True(t, c.popups.popupConfig.OAuthAutoClose) | |
| 162 | + | assert.True(t, initial.OpenInNewPane) | |
| 163 | + | assert.False(t, initial.OAuthAutoClose) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | type popupOpenerWebViewStub struct { | |
| 167 | + | *popupNavigationWebViewStub | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {} | |
| 171 | + | func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {} | |
| 172 | + | func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {} | |
| 173 | + | func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true } | |
| 174 | + | ||
| 175 | + | type popupNavigationWebViewStub struct { | |
| 176 | + | *mocks.MockWebView | |
| 177 | + | primed []string | |
| 178 | + | browsingContextDecision dto.HostDecision | |
| 179 | + | hasBrowsingContextDecision bool | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) { | |
| 183 | + | s.primed = append(s.primed, uri) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 187 | + | s.browsingContextDecision = decision | |
| 188 | + | s.hasBrowsingContextDecision = true | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) { | |
| 192 | + | return s.browsingContextDecision, s.hasBrowsingContextDecision | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {} | |
| 196 | + | func (*popupNavigationWebViewStub) SetOnClose(func()) {} | |
| 197 | + | func (*popupNavigationWebViewStub) Show() {} | |
| 198 | + | ||
| 199 | + | // stubFactory satisfies port.WebViewFactory without importing the mocks package. | |
| 200 | + | type stubFactory struct{} | |
| 201 | + | ||
| 202 | + | func (s *stubFactory) Create(_ context.Context) (port.WebView, error) { | |
| 203 | + | return nil, nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) { | |
| 207 | + | return nil, nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // --------------------------------------------------------------------------- | |
| 211 | + | // Concurrent access to pendingPopups | |
| 212 | + | // --------------------------------------------------------------------------- | |
| 213 | + | ||
| 214 | + | func TestPendingPopups_ConcurrentAccess(t *testing.T) { | |
| 215 | + | t.Parallel() | |
| 216 | + | ||
| 217 | + | c := &Coordinator{ | |
| 218 | + | popups: newPopupManager(), | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | const workers = 20 | |
| 222 | + | var wg sync.WaitGroup | |
| 223 | + | wg.Add(workers * 2) | |
| 224 | + | ||
| 225 | + | // Writers | |
| 226 | + | for i := range workers { | |
| 227 | + | go func(id int) { | |
| 228 | + | defer wg.Done() | |
| 229 | + | popupID := port.WebViewID(id) | |
| 230 | + | c.popups.mu.Lock() | |
| 231 | + | c.popups.pendingPopups[popupID] = &PendingPopup{ | |
| 232 | + | FrameName: "_blank", | |
| 233 | + | PopupType: PopupTypeTab, | |
| 234 | + | } | |
| 235 | + | c.popups.mu.Unlock() | |
| 236 | + | }(i) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Readers | |
| 240 | + | for i := range workers { | |
| 241 | + | go func(id int) { | |
| 242 | + | defer wg.Done() | |
| 243 | + | popupID := port.WebViewID(id) | |
| 244 | + | c.popups.mu.RLock() | |
| 245 | + | _ = c.popups.pendingPopups[popupID] | |
| 246 | + | c.popups.mu.RUnlock() | |
| 247 | + | }(i) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | wg.Wait() | |
| 251 | + | ||
| 252 | + | c.popups.mu.RLock() | |
| 253 | + | count := len(c.popups.pendingPopups) | |
| 254 | + | c.popups.mu.RUnlock() | |
| 255 | + | ||
| 256 | + | assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present") | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |
| 260 | + | t.Parallel() | |
| 261 | + | ||
| 262 | + | c := &Coordinator{ | |
| 263 | + | popups: newPopupManager(), | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Pre-populate | |
| 267 | + | for i := range 10 { | |
| 268 | + | c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup} | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | var wg sync.WaitGroup | |
| 272 | + | wg.Add(20) | |
| 273 | + | ||
| 274 | + | // Deleters | |
| 275 | + | for i := range 10 { | |
| 276 | + | go func(id int) { | |
| 277 | + | defer wg.Done() | |
| 278 | + | c.popups.mu.Lock() | |
| 279 | + | delete(c.popups.pendingPopups, port.WebViewID(id)) | |
| 280 | + | c.popups.mu.Unlock() | |
| 281 | + | }(i) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Readers | |
| 285 | + | for i := range 10 { | |
| 286 | + | go func(id int) { | |
| 287 | + | defer wg.Done() | |
| 288 | + | c.popups.mu.RLock() | |
| 289 | + | _ = c.popups.pendingPopups[port.WebViewID(id)] | |
| 290 | + | c.popups.mu.RUnlock() | |
| 291 | + | }(i) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | wg.Wait() | |
| 295 | + | ||
| 296 | + | c.popups.mu.RLock() | |
| 297 | + | defer c.popups.mu.RUnlock() | |
| 298 | + | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) { | |
| 302 | + | ctx := context.Background() | |
| 303 | + | parent := mocks.NewMockWebView(t) | |
| 304 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 305 | + | parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once() | |
| 306 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 307 | + | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 308 | + | popup.EXPECT().Destroy().Once() | |
| 309 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 310 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 311 | + | staging := &popupStagingHostStub{} | |
| 312 | + | c := &Coordinator{popups: newPopupManager()} | |
| 313 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |
| 314 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 315 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 316 | + | staging.attached = true | |
| 317 | + | return staging, nil | |
| 318 | + | }) | |
| 319 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 320 | + | t.Fatal("featureless _blank must not open a browser window") | |
| 321 | + | return BrowserWindowResult{}, nil | |
| 322 | + | }) | |
| 323 | + | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | |
| 324 | + | t.Fatal("featureless _blank must not use native host") | |
| 325 | + | return nil | |
| 326 | + | }) | |
| 327 | + | ||
| 328 | + | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ | |
| 329 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |
| 330 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 331 | + | }) | |
| 332 | + | require.Same(t, popup, got) | |
| 333 | + | assert.True(t, staging.attached) | |
| 334 | + | assert.False(t, staging.detached) | |
| 335 | + | popup.ready() | |
| 336 | + | popup.ready() | |
| 337 | + | assert.True(t, staging.detached) | |
| 338 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |
| 342 | + | ctx := context.Background() | |
| 343 | + | parent := mocks.NewMockWebView(t) | |
| 344 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 345 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}} | |
| 346 | + | popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 347 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 348 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 349 | + | staging := &popupStagingHostStub{} | |
| 350 | + | c := &Coordinator{popups: newPopupManager()} | |
| 351 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |
| 352 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 353 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 354 | + | staging.attached = true | |
| 355 | + | return staging, nil | |
| 356 | + | }) | |
| 357 | + | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 358 | + | assert.True(t, staging.detached) | |
| 359 | + | assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State) | |
| 360 | + | assert.Same(t, popup, input.PopupWebView) | |
| 361 | + | return nil | |
| 362 | + | }) | |
| 363 | + | ||
| 364 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ | |
| 365 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |
| 366 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 367 | + | })) | |
| 368 | + | popup.ready() | |
| 369 | + | decision, ok := popup.BrowsingContextHostDecision() | |
| 370 | + | require.True(t, ok) | |
| 371 | + | assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) { | |
| 375 | + | ctx := context.Background() | |
| 376 | + | parentPaneID := entity.PaneID("floating") | |
| 377 | + | existingPaneID := entity.PaneID("existing") | |
| 378 | + | parent := mocks.NewMockWebView(t) | |
| 379 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 380 | + | staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 381 | + | staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe() | |
| 382 | + | staged.EXPECT().Destroy().Once() | |
| 383 | + | existing := mocks.NewMockWebView(t) | |
| 384 | + | existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3) | |
| 385 | + | existing.EXPECT().IsDestroyed().Return(false).Twice() | |
| 386 | + | existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once() | |
| 387 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 388 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once() | |
| 389 | + | staging := &popupStagingHostStub{} | |
| 390 | + | c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()} | |
| 391 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 392 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 393 | + | c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) { | |
| 394 | + | windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"} | |
| 395 | + | windowID, ok := windowByPane[paneID] | |
| 396 | + | return windowID, ok | |
| 397 | + | }) | |
| 398 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 399 | + | ||
| 400 | + | require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{ | |
| 401 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared", | |
| 402 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 403 | + | })) | |
| 404 | + | c.RegisterPopupWebView(existingPaneID, existing) | |
| 405 | + | c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301)) | |
| 406 | + | staged.ready() | |
| 407 | + | staged.ready() | |
| 408 | + | ||
| 409 | + | assert.True(t, staging.detached) | |
| 410 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func TestPopupDeferredNamedReuseRaceLogsHostUnavailableAndCleansStagingExactlyOnce(t *testing.T) { | |
| 414 | + | var output bytes.Buffer | |
| 415 | + | logger := zerolog.New(&output).Level(zerolog.WarnLevel) | |
| 416 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 417 | + | parentPaneID := entity.PaneID("floating") | |
| 418 | + | existingPaneID := entity.PaneID("existing") | |
| 419 | + | parent := mocks.NewMockWebView(t) | |
| 420 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 421 | + | staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 422 | + | staged.EXPECT().ID().Return(port.WebViewID(208)).Maybe() | |
| 423 | + | staged.EXPECT().Destroy().Once() | |
| 424 | + | existing := mocks.NewMockWebView(t) | |
| 425 | + | destroyedChecks := 0 | |
| 426 | + | existing.EXPECT().IsDestroyed().RunAndReturn(func() bool { | |
| 427 | + | destroyedChecks++ | |
| 428 | + | return destroyedChecks > 1 | |
| 429 | + | }).Twice() | |
| 430 | + | existing.EXPECT().ID().Return(port.WebViewID(301)).Once() | |
| 431 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 432 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once() | |
| 433 | + | staging := &popupStagingHostStub{} | |
| 434 | + | c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()} | |
| 435 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 436 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 437 | + | c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) { | |
| 438 | + | windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"} | |
| 439 | + | windowID, ok := windowByPane[paneID] | |
| 440 | + | return windowID, ok | |
| 441 | + | }) | |
| 442 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 443 | + | ||
| 444 | + | require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{ | |
| 445 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared", | |
| 446 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 447 | + | })) | |
| 448 | + | c.RegisterPopupWebView(existingPaneID, existing) | |
| 449 | + | c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301)) | |
| 450 | + | staged.ready() | |
| 451 | + | staged.ready() | |
| 452 | + | ||
| 453 | + | assert.Equal(t, 2, destroyedChecks) | |
| 454 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 455 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 456 | + | assert.Equal(t, "host-unavailable", record["reason_code"]) | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) { | |
| 460 | + | var output bytes.Buffer | |
| 461 | + | logger := zerolog.New(&output).Level(zerolog.WarnLevel) | |
| 462 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 463 | + | parent := mocks.NewMockWebView(t) | |
| 464 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 465 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}} | |
| 466 | + | popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe() | |
| 467 | + | popup.EXPECT().Destroy().Once() | |
| 468 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 469 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 470 | + | c := &Coordinator{popups: newPopupManager()} | |
| 471 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 472 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 473 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil }) | |
| 474 | + | c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error { | |
| 475 | + | assert.False(t, input.AllowBrowserWindowFallback) | |
| 476 | + | if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) { | |
| 477 | + | input.PopupWebView.Destroy() | |
| 478 | + | } | |
| 479 | + | return nil | |
| 480 | + | }) | |
| 481 | + | ||
| 482 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ | |
| 483 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup, | |
| 484 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 485 | + | })) | |
| 486 | + | popup.ready() | |
| 487 | + | ||
| 488 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 489 | + | assert.Equal(t, "webkit", record["engine"]) | |
| 490 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 491 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 492 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 493 | + | assert.Equal(t, "native-arm-failed", record["reason_code"]) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |
| 497 | + | parent := mocks.NewMockWebView(t) | |
| 498 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 499 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 500 | + | popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe() | |
| 501 | + | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 502 | + | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 503 | + | popup.EXPECT().Destroy().Once() | |
| 504 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 505 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 506 | + | staging := &popupStagingHostStub{} | |
| 507 | + | c := &Coordinator{popups: newPopupManager()} | |
| 508 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 509 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 510 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 511 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 512 | + | return BrowserWindowResult{}, errors.New("host failed") | |
| 513 | + | }) | |
| 514 | + | ||
| 515 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{ | |
| 516 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared", | |
| 517 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 518 | + | })) | |
| 519 | + | popup.ready() | |
| 520 | + | popup.ready() | |
| 521 | + | assert.True(t, staging.detached) | |
| 522 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestPopupDeferredStagingDetachFailureCleansUpExactlyOnce(t *testing.T) { | |
| 526 | + | parent := mocks.NewMockWebView(t) | |
| 527 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 528 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 529 | + | popup.EXPECT().ID().Return(port.WebViewID(209)).Maybe() | |
| 530 | + | popup.EXPECT().Destroy().Once() | |
| 531 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 532 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 533 | + | staging := &popupStagingHostStub{detachErr: errors.New("detach failed")} | |
| 534 | + | c := &Coordinator{popups: newPopupManager()} | |
| 535 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 536 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 537 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 538 | + | ||
| 539 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{ | |
| 540 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", | |
| 541 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 542 | + | })) | |
| 543 | + | popup.ready() | |
| 544 | + | popup.ready() | |
| 545 | + | ||
| 546 | + | assert.True(t, staging.detached) | |
| 547 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) { | |
| 551 | + | parent := mocks.NewMockWebView(t) | |
| 552 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 553 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 554 | + | popup.EXPECT().Destroy().Once() | |
| 555 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 556 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 557 | + | c := &Coordinator{popups: newPopupManager()} | |
| 558 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 559 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 560 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 561 | + | return nil, errors.New("stage failed") | |
| 562 | + | }) | |
| 563 | + | ||
| 564 | + | assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) { | |
| 568 | + | for _, tc := range []struct { | |
| 569 | + | name string | |
| 570 | + | prepare func(*Coordinator) | |
| 571 | + | trigger func(*Coordinator) | |
| 572 | + | }{ | |
| 573 | + | {name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }}, | |
| 574 | + | {name: "owner window teardown", prepare: func(c *Coordinator) { | |
| 575 | + | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true }) | |
| 576 | + | }, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }}, | |
| 577 | + | {name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }}, | |
| 578 | + | } { | |
| 579 | + | t.Run(tc.name, func(t *testing.T) { | |
| 580 | + | parent := mocks.NewMockWebView(t) | |
| 581 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 582 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 583 | + | popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe() | |
| 584 | + | popup.EXPECT().Destroy().Once() | |
| 585 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 586 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 587 | + | staging := &popupStagingHostStub{} | |
| 588 | + | c := &Coordinator{popups: newPopupManager()} | |
| 589 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 590 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 591 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 592 | + | if tc.prepare != nil { | |
| 593 | + | tc.prepare(c) | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{ | |
| 597 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", | |
| 598 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 599 | + | })) | |
| 600 | + | require.Len(t, c.popups.deferredPopups, 1) | |
| 601 | + | tc.trigger(c) | |
| 602 | + | tc.trigger(c) | |
| 603 | + | ||
| 604 | + | assert.Empty(t, c.popups.deferredPopups) | |
| 605 | + | assert.Empty(t, c.popups.pendingPopups) | |
| 606 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 607 | + | assert.Nil(t, popup.onReady) | |
| 608 | + | assert.Nil(t, popup.onClose) | |
| 609 | + | }) | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) { | |
| 614 | + | for _, tc := range []struct { | |
| 615 | + | name string | |
| 616 | + | close bool | |
| 617 | + | }{{name: "unknown at ready"}, {name: "close before ready", close: true}} { | |
| 618 | + | t.Run(tc.name, func(t *testing.T) { | |
| 619 | + | parent := mocks.NewMockWebView(t) | |
| 620 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 621 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}} | |
| 622 | + | popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe() | |
| 623 | + | popup.EXPECT().Destroy().Once() | |
| 624 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 625 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 626 | + | staging := &popupStagingHostStub{} | |
| 627 | + | c := &Coordinator{popups: newPopupManager()} | |
| 628 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 629 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 630 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 631 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 632 | + | if tc.close { | |
| 633 | + | popup.close() | |
| 634 | + | popup.close() | |
| 635 | + | } else { | |
| 636 | + | popup.ready() | |
| 637 | + | popup.ready() | |
| 638 | + | } | |
| 639 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 640 | + | }) | |
| 641 | + | } | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | |
| 645 | + | type deferredPopupWebViewStub struct { | |
| 646 | + | *mocks.MockWebView | |
| 647 | + | features dto.PopupFeatures | |
| 648 | + | onReady func() | |
| 649 | + | onClose func() | |
| 650 | + | browsingContextDecision dto.HostDecision | |
| 651 | + | hasBrowsingContextDecision bool | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | |
| 655 | + | func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {} | |
| 656 | + | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | |
| 657 | + | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | |
| 658 | + | func (s *deferredPopupWebViewStub) Show() {} | |
| 659 | + | func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 660 | + | s.browsingContextDecision = decision | |
| 661 | + | s.hasBrowsingContextDecision = true | |
| 662 | + | } | |
| 663 | + | func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) { | |
| 664 | + | return s.browsingContextDecision, s.hasBrowsingContextDecision | |
| 665 | + | } | |
| 666 | + | func (s *deferredPopupWebViewStub) ready() { | |
| 667 | + | if s.onReady != nil { | |
| 668 | + | s.onReady() | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | func (s *deferredPopupWebViewStub) close() { | |
| 672 | + | if s.onClose != nil { | |
| 673 | + | s.onClose() | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | type popupStagingHostStub struct { | |
| 678 | + | attached, detached bool | |
| 679 | + | destroyCalls int | |
| 680 | + | detachErr error | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr } | |
| 684 | + | func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ } | |
M internal/ui/coordinator/content/popup_manager.go +189 -98
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | ) | |
| @@ -17,20 +18,34 @@ import ( | |||
| 17 | 18 | // adapter layer so popup pane bookkeeping and workspace orchestration do not | |
| 18 | 19 | // leak into the application/usecase or domain layers. | |
| 19 | 20 | type popupManager struct { | |
| 20 | - | factory port.WebViewFactory | |
| 21 | - | popupConfig entity.BrowsingContextConfig | |
| 22 | - | hasPopupConfig bool | |
| 23 | - | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | |
| 24 | - | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error | |
| 25 | - | onClosePane func(ctx context.Context, paneID entity.PaneID) error | |
| 26 | - | generatePaneID func() string | |
| 27 | - | windowIDForPane func(entity.PaneID) (string, bool) | |
| 28 | - | policy browsingContextPolicy | |
| 29 | - | namedContexts *namedBrowsingContextRegistry | |
| 30 | - | pendingPopups map[port.WebViewID]*PendingPopup | |
| 31 | - | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 32 | - | popupRefresh map[entity.PaneID]*time.Timer | |
| 33 | - | mu sync.RWMutex | |
| 21 | + | factory port.WebViewFactory | |
| 22 | + | popupConfig entity.BrowsingContextConfig | |
| 23 | + | hasPopupConfig bool | |
| 24 | + | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | |
| 25 | + | onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error) | |
| 26 | + | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error | |
| 27 | + | onStagePopup func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error) | |
| 28 | + | onClosePane func(ctx context.Context, paneID entity.PaneID) error | |
| 29 | + | generatePaneID func() string | |
| 30 | + | windowIDForPane func(entity.PaneID) (string, bool) | |
| 31 | + | sourceHostForPane func(entity.PaneID) dto.SourceHostKind | |
| 32 | + | policy usecase.BrowsingContextPolicy | |
| 33 | + | namedContexts *namedBrowsingContextRegistry | |
| 34 | + | pendingPopups map[port.WebViewID]*PendingPopup | |
| 35 | + | deferredPopups map[port.WebViewID]*deferredPendingPopup | |
| 36 | + | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 37 | + | popupRefresh map[entity.PaneID]*time.Timer | |
| 38 | + | mu sync.RWMutex | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type deferredPendingPopup struct { | |
| 42 | + | *PendingPopup | |
| 43 | + | Request port.PopupRequest | |
| 44 | + | Decision dto.HostDecision | |
| 45 | + | StagingHost PopupStagingHost | |
| 46 | + | OwnerWindowID string | |
| 47 | + | ParentWebView port.WebView | |
| 48 | + | cleanupOnce sync.Once | |
| 34 | 49 | } | |
| 35 | 50 | ||
| 36 | 51 | type popupOAuthState struct { | |
| @@ -83,6 +98,9 @@ func (pm *popupManager) ensureInitialized() { | |||
| 83 | 98 | if pm.pendingPopups == nil { | |
| 84 | 99 | pm.pendingPopups = make(map[port.WebViewID]*PendingPopup) | |
| 85 | 100 | } | |
| 101 | + | if pm.deferredPopups == nil { | |
| 102 | + | pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup) | |
| 103 | + | } | |
| 86 | 104 | if pm.namedContexts == nil { | |
| 87 | 105 | pm.namedContexts = newNamedBrowsingContextRegistry() | |
| 88 | 106 | } | |
| @@ -143,6 +161,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool | |||
| 143 | 161 | pm.windowIDForPane = fn | |
| 144 | 162 | } | |
| 145 | 163 | ||
| 164 | + | func (pm *popupManager) setSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) { | |
| 165 | + | if pm != nil { | |
| 166 | + | pm.sourceHostForPane = fn | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (pm *popupManager) resolveSourceHost(paneID entity.PaneID) dto.SourceHostKind { | |
| 171 | + | if pm != nil && pm.sourceHostForPane != nil { | |
| 172 | + | if kind := pm.sourceHostForPane(paneID); kind != "" { | |
| 173 | + | return kind | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | return dto.SourceHostWorkspace | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (pm *popupManager) setOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) { | |
| 180 | + | if pm != nil { | |
| 181 | + | pm.onOpenBrowserWindow = fn | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (pm *popupManager) setOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) { | |
| 186 | + | if pm != nil { | |
| 187 | + | pm.onStagePopup = fn | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (pm *popupManager) browsingContextRoutingConfig( | |
| 192 | + | ctx context.Context, | |
| 193 | + | parentPaneID entity.PaneID, | |
| 194 | + | ) (*entity.BrowsingContextConfig, dto.SourceHostKind, bool) { | |
| 195 | + | cfg := pm.currentPopupConfig() | |
| 196 | + | sourceHost := pm.resolveSourceHost(parentPaneID) | |
| 197 | + | if sourceHost == dto.SourceHostWorkspace && cfg != nil && !cfg.OpenInNewPane { | |
| 198 | + | logging.FromContext(ctx).Debug().Msg("workspace browsing contexts disabled by config") | |
| 199 | + | return cfg, sourceHost, false | |
| 200 | + | } | |
| 201 | + | if pm.factory == nil { | |
| 202 | + | logging.FromContext(ctx).Warn().Msg("no webview factory, cannot create browsing context") | |
| 203 | + | return cfg, sourceHost, false | |
| 204 | + | } | |
| 205 | + | return cfg, sourceHost, true | |
| 206 | + | } | |
| 207 | + | ||
| 146 | 208 | func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) { | |
| 147 | 209 | behavior := GetBehavior(PopupTypeTab, cfg) | |
| 148 | 210 | placement := "right" | |
| @@ -209,7 +271,7 @@ func (pm *popupManager) lookupReusableNamedPopup( | |||
| 209 | 271 | return nil, false | |
| 210 | 272 | } | |
| 211 | 273 | ||
| 212 | - | name := reusableBrowsingContextName(frameName) | |
| 274 | + | name := usecase.ReusableBrowsingContextName(frameName) | |
| 213 | 275 | if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil { | |
| 214 | 276 | return nil, false | |
| 215 | 277 | } | |
| @@ -227,18 +289,32 @@ func (pm *popupManager) lookupReusableNamedPopup( | |||
| 227 | 289 | } | |
| 228 | 290 | ||
| 229 | 291 | func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) { | |
| 230 | - | if pm == nil || pm.namedContexts == nil || wv == nil { | |
| 292 | + | if pm == nil || pm.windowIDForPane == nil { | |
| 231 | 293 | return | |
| 232 | 294 | } | |
| 233 | - | name := reusableBrowsingContextName(frameName) | |
| 234 | - | if name == "" || pm.windowIDForPane == nil { | |
| 295 | + | hostWindowID, ok := pm.windowIDForPane(paneID) | |
| 296 | + | if !ok { | |
| 235 | 297 | return | |
| 236 | 298 | } | |
| 237 | - | windowID, ok := pm.windowIDForPane(parentPaneID) | |
| 238 | - | if !ok || windowID == "" { | |
| 299 | + | pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func (pm *popupManager) storeReusableNamedPopupWithHost( | |
| 303 | + | parentPaneID entity.PaneID, | |
| 304 | + | frameName string, | |
| 305 | + | paneID entity.PaneID, | |
| 306 | + | wv port.WebView, | |
| 307 | + | hostWindowID string, | |
| 308 | + | ) { | |
| 309 | + | if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil { | |
| 239 | 310 | return | |
| 240 | 311 | } | |
| 241 | - | pm.namedContexts.Register(windowID, name, paneID, wv.ID()) | |
| 312 | + | name := usecase.ReusableBrowsingContextName(frameName) | |
| 313 | + | ownerWindowID, ok := pm.windowIDForPane(parentPaneID) | |
| 314 | + | if name == "" || !ok || ownerWindowID == "" || hostWindowID == "" { | |
| 315 | + | return | |
| 316 | + | } | |
| 317 | + | pm.namedContexts.Register(ownerWindowID, hostWindowID, name, paneID, wv.ID()) | |
| 242 | 318 | } | |
| 243 | 319 | ||
| 244 | 320 | func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) { | |
| @@ -295,6 +371,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, | |||
| 295 | 371 | return pending, ok | |
| 296 | 372 | } | |
| 297 | 373 | ||
| 374 | + | func (pm *popupManager) storeDeferredPopup(popupID port.WebViewID, pending *deferredPendingPopup) { | |
| 375 | + | if pm == nil || pending == nil { | |
| 376 | + | return | |
| 377 | + | } | |
| 378 | + | pm.mu.Lock() | |
| 379 | + | pm.deferredPopups[popupID] = pending | |
| 380 | + | pm.mu.Unlock() | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func (pm *popupManager) takeDeferredPopup(popupID port.WebViewID) (*deferredPendingPopup, bool) { | |
| 384 | + | if pm == nil { | |
| 385 | + | return nil, false | |
| 386 | + | } | |
| 387 | + | pm.mu.Lock() | |
| 388 | + | defer pm.mu.Unlock() | |
| 389 | + | pending, ok := pm.deferredPopups[popupID] | |
| 390 | + | if ok { | |
| 391 | + | delete(pm.deferredPopups, popupID) | |
| 392 | + | } | |
| 393 | + | return pending, ok | |
| 394 | + | } | |
| 395 | + | ||
| 298 | 396 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | |
| 299 | 397 | if pm == nil { | |
| 300 | 398 | return | |
| @@ -451,45 +549,6 @@ func (pm *popupManager) popupParentURIAtOpen( | |||
| 451 | 549 | return parentURIAtOpen | |
| 452 | 550 | } | |
| 453 | 551 | ||
| 454 | - | func (pm *popupManager) openNativePopup( | |
| 455 | - | ctx context.Context, | |
| 456 | - | parentPaneID entity.PaneID, | |
| 457 | - | parentID port.WebViewID, | |
| 458 | - | parentURIAtOpen string, | |
| 459 | - | req port.PopupRequest, | |
| 460 | - | decision dto.HostDecision, | |
| 461 | - | ) port.WebView { | |
| 462 | - | log := logging.FromContext(ctx) | |
| 463 | - | if pm.onOpenNativePopup == nil { | |
| 464 | - | log.Warn().Msg("native popup host callback not configured") | |
| 465 | - | return nil | |
| 466 | - | } | |
| 467 | - | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 468 | - | if err != nil { | |
| 469 | - | log.Error().Err(err).Msg("failed to create webview for native popup") | |
| 470 | - | return nil | |
| 471 | - | } | |
| 472 | - | cfg := pm.currentPopupConfig() | |
| 473 | - | pm.setBrowsingContextDecision(popupWV, decision) | |
| 474 | - | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |
| 475 | - | ParentPaneID: parentPaneID, | |
| 476 | - | ParentWebViewID: parentID, | |
| 477 | - | ParentURIAtOpen: parentURIAtOpen, | |
| 478 | - | PopupWebView: popupWV, | |
| 479 | - | TargetURI: req.TargetURI, | |
| 480 | - | Request: req, | |
| 481 | - | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |
| 482 | - | }); err != nil { | |
| 483 | - | log.Error().Err(err).Msg("failed to open native popup host") | |
| 484 | - | popupWV.Destroy() | |
| 485 | - | return nil | |
| 486 | - | } | |
| 487 | - | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 488 | - | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 489 | - | } | |
| 490 | - | return popupWV | |
| 491 | - | } | |
| 492 | - | ||
| 493 | 552 | func (pm *popupManager) handlePopupCreate( | |
| 494 | 553 | ctx context.Context, | |
| 495 | 554 | hooks popupCoordinatorHooks, | |
| @@ -499,21 +558,8 @@ func (pm *popupManager) handlePopupCreate( | |||
| 499 | 558 | ) port.WebView { | |
| 500 | 559 | log := logging.FromContext(ctx) | |
| 501 | 560 | ||
| 502 | - | log.Debug(). | |
| 503 | - | Str("parent_pane", string(parentPaneID)). | |
| 504 | - | Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)). | |
| 505 | - | Str("frame_name", req.FrameName). | |
| 506 | - | Bool("user_gesture", req.IsUserGesture). | |
| 507 | - | Bool("no_javascript_access", req.NoJavaScriptAccess). | |
| 508 | - | Msg("popup create request") | |
| 509 | - | ||
| 510 | - | cfg := pm.currentPopupConfig() | |
| 511 | - | if cfg != nil && !cfg.OpenInNewPane { | |
| 512 | - | log.Debug().Msg("popups disabled by config, blocking") | |
| 513 | - | return nil | |
| 514 | - | } | |
| 515 | - | if pm.factory == nil { | |
| 516 | - | log.Warn().Msg("no webview factory, cannot create popup") | |
| 561 | + | cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID) | |
| 562 | + | if !routingEnabled { | |
| 517 | 563 | return nil | |
| 518 | 564 | } | |
| 519 | 565 | ||
| @@ -521,16 +567,13 @@ func (pm *popupManager) handlePopupCreate( | |||
| 521 | 567 | parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI) | |
| 522 | 568 | ||
| 523 | 569 | request := buildPopupBrowsingContextRequest(req) | |
| 570 | + | request.SourceHost = sourceHost | |
| 524 | 571 | namedContextExists := false | |
| 525 | 572 | if !req.NoJavaScriptAccess { | |
| 526 | 573 | _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks) | |
| 527 | 574 | } | |
| 528 | 575 | decision := pm.policy.Decide(request, namedContextExists) | |
| 529 | - | log.Debug(). | |
| 530 | - | Str("decision", string(decision.Kind)). | |
| 531 | - | Str("reason", decision.Reason). | |
| 532 | - | Str("context_name", decision.BrowsingContextName). | |
| 533 | - | Msg("browsing context host decision") | |
| 576 | + | logBrowsingContextDecision(*log, request, decision) | |
| 534 | 577 | ||
| 535 | 578 | switch decision.Kind { | |
| 536 | 579 | case dto.HostDecisionDeny: | |
| @@ -543,14 +586,24 @@ func (pm *popupManager) handlePopupCreate( | |||
| 543 | 586 | reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI) | |
| 544 | 587 | if ok { | |
| 545 | 588 | pm.setBrowsingContextDecision(reused, decision) | |
| 546 | - | return reused | |
| 589 | + | // The existing browsing context already owns its engine view. Returning | |
| 590 | + | // it would ask CEF/WebKit to create a second native popup around an | |
| 591 | + | // already-hosted view, so block engine creation after navigation. | |
| 592 | + | return nil | |
| 547 | 593 | } | |
| 548 | 594 | log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable") | |
| 549 | - | decision.Kind = dto.HostDecisionCreatePane | |
| 550 | - | decision.ReuseContextName = "" | |
| 551 | - | decision.Reason = "named browsing context unavailable; creating replacement pane" | |
| 552 | - | case dto.HostDecisionCreateNativeWin: | |
| 553 | - | return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 595 | + | // Lookup removes the stale registry entry. Re-enter policy once so the | |
| 596 | + | // replacement host remains source-aware instead of forcing a workspace pane. | |
| 597 | + | return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req) | |
| 598 | + | case dto.HostDecisionNavigateSource: | |
| 599 | + | pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision) | |
| 600 | + | return nil | |
| 601 | + | case dto.HostDecisionCreateBrowserWindow: | |
| 602 | + | return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, true) | |
| 603 | + | case dto.HostDecisionCreateNativePopup: | |
| 604 | + | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 605 | + | case dto.HostDecisionAwaitPopupFeatures: | |
| 606 | + | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision) | |
| 554 | 607 | case dto.HostDecisionCreatePane: | |
| 555 | 608 | // Continue below. | |
| 556 | 609 | break | |
| @@ -812,6 +865,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin | |||
| 812 | 865 | log.Info().Str("pane_id", string(paneID)).Msg("popup closed") | |
| 813 | 866 | } | |
| 814 | 867 | ||
| 868 | + | func (pm *popupManager) openMiddleClickBrowserWindow( | |
| 869 | + | ctx context.Context, | |
| 870 | + | hooks popupCoordinatorHooks, | |
| 871 | + | parentPaneID entity.PaneID, | |
| 872 | + | parentWV port.WebView, | |
| 873 | + | uri string, | |
| 874 | + | decision dto.HostDecision, | |
| 875 | + | ) bool { | |
| 876 | + | req := port.PopupRequest{ | |
| 877 | + | TargetURI: uri, | |
| 878 | + | FrameName: "_blank", | |
| 879 | + | TargetDisposition: dto.WindowDispositionNewTab, | |
| 880 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 881 | + | } | |
| 882 | + | newWV := pm.openBrowserWindow(ctx, hooks, parentPaneID, parentWV.ID(), req, decision, true) | |
| 883 | + | if newWV == nil { | |
| 884 | + | return false | |
| 885 | + | } | |
| 886 | + | if err := newWV.LoadURI(ctx, uri); err != nil { | |
| 887 | + | logging.FromContext(ctx).Error().Err(err). | |
| 888 | + | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 889 | + | Msg("failed to load URI in detached window") | |
| 890 | + | } | |
| 891 | + | return true | |
| 892 | + | } | |
| 893 | + | ||
| 815 | 894 | func (pm *popupManager) handleLinkMiddleClick( | |
| 816 | 895 | ctx context.Context, | |
| 817 | 896 | hooks popupCoordinatorHooks, | |
| @@ -825,13 +904,8 @@ func (pm *popupManager) handleLinkMiddleClick( | |||
| 825 | 904 | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 826 | 905 | Msg("middle-click/ctrl+click on link") | |
| 827 | 906 | ||
| 828 | - | cfg := pm.currentPopupConfig() | |
| 829 | - | if cfg != nil && !cfg.OpenInNewPane { | |
| 830 | - | log.Debug().Msg("popups disabled by config, ignoring middle-click") | |
| 831 | - | return false | |
| 832 | - | } | |
| 833 | - | if pm.factory == nil { | |
| 834 | - | log.Warn().Msg("no webview factory, cannot handle middle-click") | |
| 907 | + | _, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID) | |
| 908 | + | if !routingEnabled { | |
| 835 | 909 | return false | |
| 836 | 910 | } | |
| 837 | 911 | if hooks.getWebView == nil { | |
| @@ -844,16 +918,33 @@ func (pm *popupManager) handleLinkMiddleClick( | |||
| 844 | 918 | return false | |
| 845 | 919 | } | |
| 846 | 920 | ||
| 847 | - | decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false) | |
| 848 | - | log.Debug(). | |
| 849 | - | Str("decision", string(decision.Kind)). | |
| 850 | - | Str("reason", decision.Reason). | |
| 851 | - | Msg("middle-click browsing context host decision") | |
| 921 | + | request := buildLinkBrowsingContextRequest(parentWV.ID(), uri) | |
| 922 | + | request.SourceHost = sourceHost | |
| 923 | + | decision := pm.policy.Decide(request, false) | |
| 924 | + | logBrowsingContextDecision(*log, request, decision) | |
| 925 | + | if decision.Kind == dto.HostDecisionNavigateSource { | |
| 926 | + | return pm.navigateSource(ctx, parentWV, uri) | |
| 927 | + | } | |
| 928 | + | if decision.Kind == dto.HostDecisionCreateBrowserWindow { | |
| 929 | + | return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision) | |
| 930 | + | } | |
| 852 | 931 | if decision.Kind != dto.HostDecisionCreatePane { | |
| 853 | 932 | log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted") | |
| 854 | 933 | return false | |
| 855 | 934 | } | |
| 856 | 935 | ||
| 936 | + | return pm.openMiddleClickPane(ctx, hooks, parentPaneID, parentWV, uri, decision) | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | func (pm *popupManager) openMiddleClickPane( | |
| 940 | + | ctx context.Context, | |
| 941 | + | hooks popupCoordinatorHooks, | |
| 942 | + | parentPaneID entity.PaneID, | |
| 943 | + | parentWV port.WebView, | |
| 944 | + | uri string, | |
| 945 | + | decision dto.HostDecision, | |
| 946 | + | ) bool { | |
| 947 | + | log := logging.FromContext(ctx) | |
| 857 | 948 | newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true) | |
| 858 | 949 | if err != nil { | |
| 859 | 950 | log.Error().Err(err).Msg("failed to create webview for middle-click") | |
M internal/ui/coordinator/content/popup_test.go +0 -762
| @@ -1,20 +1,11 @@ | |||
| 1 | 1 | package content | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "sync" | |
| 7 | 4 | "testing" | |
| 8 | 5 | ||
| 9 | 6 | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/mock" | |
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | 7 | ||
| 13 | - | "github.com/bnema/dumber/internal/application/dto" | |
| 14 | - | "github.com/bnema/dumber/internal/application/port" | |
| 15 | - | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 16 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | - | domainerrors "github.com/bnema/dumber/internal/domain/errors" | |
| 18 | 9 | ) | |
| 19 | 10 | ||
| 20 | 11 | // --------------------------------------------------------------------------- | |
| @@ -127,756 +118,3 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) { | |||
| 127 | 118 | } | |
| 128 | 119 | assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg)) | |
| 129 | 120 | } | |
| 130 | - | ||
| 131 | - | func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) { | |
| 132 | - | t.Helper() | |
| 133 | - | ||
| 134 | - | ctx := context.Background() | |
| 135 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 136 | - | parentWV := mocks.NewMockWebView(t) | |
| 137 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 138 | - | ||
| 139 | - | popupWV := mocks.NewMockWebView(t) | |
| 140 | - | popupWV.EXPECT().ID().Return(popupID).Maybe() | |
| 141 | - | ||
| 142 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 143 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 144 | - | ||
| 145 | - | c := &Coordinator{ | |
| 146 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 147 | - | popups: newPopupManager(), | |
| 148 | - | } | |
| 149 | - | c.SetPopupConfig(factory, nil, func() string { return "popup-pane" }) | |
| 150 | - | ||
| 151 | - | return ctx, parentPaneID, parentWV, popupWV, c | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |
| 155 | - | ctx := context.Background() | |
| 156 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 157 | - | parentWV := mocks.NewMockWebView(t) | |
| 158 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 159 | - | ||
| 160 | - | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 161 | - | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe() | |
| 162 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 163 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 164 | - | ||
| 165 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 166 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 167 | - | ||
| 168 | - | c := &Coordinator{ | |
| 169 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 170 | - | popups: newPopupManager(), | |
| 171 | - | } | |
| 172 | - | c.SetPopupConfig(factory, nil, nil) | |
| 173 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 174 | - | return nil | |
| 175 | - | }) | |
| 176 | - | ||
| 177 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 178 | - | TargetURI: "https://example.com/popup", | |
| 179 | - | FrameName: "auth-popup", | |
| 180 | - | IsUserGesture: true, | |
| 181 | - | NoJavaScriptAccess: true, | |
| 182 | - | }) | |
| 183 | - | ||
| 184 | - | require.Same(t, popupWV, created) | |
| 185 | - | require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) { | |
| 189 | - | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150)) | |
| 190 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 191 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 192 | - | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 193 | - | popupWV.EXPECT().URI().Return("").Once() | |
| 194 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once() | |
| 195 | - | ||
| 196 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 197 | - | require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID) | |
| 198 | - | require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID)) | |
| 199 | - | return nil | |
| 200 | - | }) | |
| 201 | - | ||
| 202 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 203 | - | TargetURI: "https://example.com/popup", | |
| 204 | - | FrameName: "auth-popup", | |
| 205 | - | IsUserGesture: true, | |
| 206 | - | NoJavaScriptAccess: true, | |
| 207 | - | }) | |
| 208 | - | ||
| 209 | - | require.Same(t, popupWV, created) | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) { | |
| 213 | - | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151)) | |
| 214 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 215 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 216 | - | popupWV.EXPECT().Destroy().Once() | |
| 217 | - | ||
| 218 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 219 | - | require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID)) | |
| 220 | - | return assert.AnError | |
| 221 | - | }) | |
| 222 | - | ||
| 223 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 224 | - | TargetURI: "https://example.com/popup", | |
| 225 | - | FrameName: "auth-popup", | |
| 226 | - | IsUserGesture: true, | |
| 227 | - | NoJavaScriptAccess: true, | |
| 228 | - | }) | |
| 229 | - | ||
| 230 | - | require.Nil(t, created) | |
| 231 | - | require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane"))) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) { | |
| 235 | - | ctx := context.Background() | |
| 236 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 237 | - | parentWV := mocks.NewMockWebView(t) | |
| 238 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 239 | - | ||
| 240 | - | popupWV := mocks.NewMockWebView(t) | |
| 241 | - | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe() | |
| 242 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 243 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 244 | - | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 245 | - | popupWV.EXPECT().URI().Return("").Once() | |
| 246 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once() | |
| 247 | - | ||
| 248 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 249 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 250 | - | ||
| 251 | - | c := &Coordinator{ | |
| 252 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 253 | - | popups: newPopupManager(), | |
| 254 | - | } | |
| 255 | - | c.SetPopupConfig(factory, nil, nil) | |
| 256 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 257 | - | assert.Equal(t, "https://example.com/noopener", input.TargetURI) | |
| 258 | - | return nil | |
| 259 | - | }) | |
| 260 | - | ||
| 261 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 262 | - | TargetURI: "https://example.com/noopener", | |
| 263 | - | FrameName: "auth-popup", | |
| 264 | - | IsUserGesture: true, | |
| 265 | - | NoJavaScriptAccess: true, | |
| 266 | - | }) | |
| 267 | - | ||
| 268 | - | require.Same(t, popupWV, created) | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) { | |
| 272 | - | ctx := context.Background() | |
| 273 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 274 | - | parentWV := mocks.NewMockWebView(t) | |
| 275 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 276 | - | ||
| 277 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 278 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once() | |
| 279 | - | ||
| 280 | - | insertCalls := 0 | |
| 281 | - | c := &Coordinator{ | |
| 282 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 283 | - | popups: newPopupManager(), | |
| 284 | - | } | |
| 285 | - | c.SetPopupConfig(factory, nil, nil) | |
| 286 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 287 | - | insertCalls++ | |
| 288 | - | return nil | |
| 289 | - | }) | |
| 290 | - | ||
| 291 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 292 | - | TargetURI: "https://example.com/edit", | |
| 293 | - | FrameName: "_blank", | |
| 294 | - | IsUserGesture: true, | |
| 295 | - | }) | |
| 296 | - | ||
| 297 | - | require.Nil(t, created) | |
| 298 | - | assert.Equal(t, 0, insertCalls) | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) { | |
| 302 | - | ctx := context.Background() | |
| 303 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 304 | - | parentWV := mocks.NewMockWebView(t) | |
| 305 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 306 | - | ||
| 307 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 308 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once() | |
| 309 | - | ||
| 310 | - | insertCalls := 0 | |
| 311 | - | c := &Coordinator{ | |
| 312 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 313 | - | popups: newPopupManager(), | |
| 314 | - | } | |
| 315 | - | c.SetPopupConfig(factory, nil, nil) | |
| 316 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 317 | - | insertCalls++ | |
| 318 | - | return nil | |
| 319 | - | }) | |
| 320 | - | ||
| 321 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 322 | - | TargetURI: "https://example.com/edit", | |
| 323 | - | FrameName: "_blank", | |
| 324 | - | IsUserGesture: true, | |
| 325 | - | }) | |
| 326 | - | ||
| 327 | - | require.Nil(t, created) | |
| 328 | - | assert.Equal(t, 0, insertCalls) | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) { | |
| 332 | - | ctx := context.Background() | |
| 333 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 334 | - | parentWV := mocks.NewMockWebView(t) | |
| 335 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 336 | - | ||
| 337 | - | newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 338 | - | newWV.EXPECT().ID().Return(port.WebViewID(301)).Maybe() | |
| 339 | - | newWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 340 | - | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 341 | - | newWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle-click").Return(nil).Maybe() | |
| 342 | - | ||
| 343 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 344 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Maybe() | |
| 345 | - | ||
| 346 | - | insertCalls := 0 | |
| 347 | - | c := &Coordinator{ | |
| 348 | - | webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, | |
| 349 | - | popups: newPopupManager(), | |
| 350 | - | } | |
| 351 | - | c.SetPopupConfig(factory, nil, nil) | |
| 352 | - | c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error { | |
| 353 | - | insertCalls++ | |
| 354 | - | assert.Equal(t, parentPaneID, input.ParentPaneID) | |
| 355 | - | assert.Same(t, newWV, input.WebView) | |
| 356 | - | assert.Equal(t, entity.PopupBehaviorSplit, input.Behavior) | |
| 357 | - | return nil | |
| 358 | - | }) | |
| 359 | - | ||
| 360 | - | handled := c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle-click") | |
| 361 | - | assert.True(t, handled) | |
| 362 | - | assert.Equal(t, 1, insertCalls) | |
| 363 | - | decision, hasDecision := newWV.BrowsingContextHostDecision() | |
| 364 | - | require.True(t, hasDecision) | |
| 365 | - | assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) { | |
| 369 | - | ctx := context.Background() | |
| 370 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 371 | - | parentWV := mocks.NewMockWebView(t) | |
| 372 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 373 | - | parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once() | |
| 374 | - | ||
| 375 | - | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 376 | - | popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe() | |
| 377 | - | ||
| 378 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 379 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 380 | - | ||
| 381 | - | insertCalls := 0 | |
| 382 | - | nativeCalls := 0 | |
| 383 | - | c := &Coordinator{ | |
| 384 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 385 | - | popups: newPopupManager(), | |
| 386 | - | } | |
| 387 | - | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil) | |
| 388 | - | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 389 | - | insertCalls++ | |
| 390 | - | return nil | |
| 391 | - | }) | |
| 392 | - | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 393 | - | nativeCalls++ | |
| 394 | - | assert.Equal(t, parentPaneID, input.ParentPaneID) | |
| 395 | - | assert.Equal(t, popupWV, input.PopupWebView) | |
| 396 | - | assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI) | |
| 397 | - | assert.True(t, input.ObserveOAuthAutoClose) | |
| 398 | - | return nil | |
| 399 | - | }) | |
| 400 | - | ||
| 401 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 402 | - | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 403 | - | FrameName: "oauth-popup", | |
| 404 | - | IsUserGesture: true, | |
| 405 | - | }) | |
| 406 | - | ||
| 407 | - | require.Equal(t, popupWV, created) | |
| 408 | - | assert.Equal(t, 0, insertCalls) | |
| 409 | - | assert.Equal(t, 1, nativeCalls) | |
| 410 | - | assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed) | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) { | |
| 414 | - | ctx := context.Background() | |
| 415 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 416 | - | parentWV := mocks.NewMockWebView(t) | |
| 417 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 418 | - | ||
| 419 | - | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 420 | - | popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe() | |
| 421 | - | ||
| 422 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 423 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 424 | - | ||
| 425 | - | insertCalls := 0 | |
| 426 | - | nativeCalls := 0 | |
| 427 | - | c := &Coordinator{ | |
| 428 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 429 | - | popups: newPopupManager(), | |
| 430 | - | } | |
| 431 | - | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil) | |
| 432 | - | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 433 | - | insertCalls++ | |
| 434 | - | return nil | |
| 435 | - | }) | |
| 436 | - | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 437 | - | nativeCalls++ | |
| 438 | - | assert.False(t, input.ObserveOAuthAutoClose) | |
| 439 | - | return nil | |
| 440 | - | }) | |
| 441 | - | ||
| 442 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 443 | - | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 444 | - | FrameName: "oauth-popup", | |
| 445 | - | IsUserGesture: true, | |
| 446 | - | }) | |
| 447 | - | ||
| 448 | - | require.Equal(t, popupWV, created) | |
| 449 | - | assert.Equal(t, 1, nativeCalls) | |
| 450 | - | assert.Equal(t, 0, insertCalls) | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) { | |
| 454 | - | ctx := context.Background() | |
| 455 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 456 | - | parentWV := mocks.NewMockWebView(t) | |
| 457 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 458 | - | ||
| 459 | - | firstWV := mocks.NewMockWebView(t) | |
| 460 | - | firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe() | |
| 461 | - | firstWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 462 | - | firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 463 | - | firstWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 464 | - | firstWV.EXPECT().URI().Return("").Maybe() | |
| 465 | - | firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe() | |
| 466 | - | ||
| 467 | - | secondWV := mocks.NewMockWebView(t) | |
| 468 | - | secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe() | |
| 469 | - | secondWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 470 | - | secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 471 | - | secondWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 472 | - | secondWV.EXPECT().URI().Return("").Maybe() | |
| 473 | - | secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe() | |
| 474 | - | ||
| 475 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 476 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once() | |
| 477 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once() | |
| 478 | - | ||
| 479 | - | insertCalls := 0 | |
| 480 | - | c := &Coordinator{ | |
| 481 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 482 | - | popups: newPopupManager(), | |
| 483 | - | } | |
| 484 | - | c.SetPopupConfig(factory, nil, nil) | |
| 485 | - | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 486 | - | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 487 | - | insertCalls++ | |
| 488 | - | return nil | |
| 489 | - | }) | |
| 490 | - | ||
| 491 | - | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 492 | - | TargetURI: "https://example.com/first", | |
| 493 | - | FrameName: "shared-pane", | |
| 494 | - | IsUserGesture: true, | |
| 495 | - | NoJavaScriptAccess: true, | |
| 496 | - | }) | |
| 497 | - | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 498 | - | TargetURI: "https://example.com/second", | |
| 499 | - | FrameName: "shared-pane", | |
| 500 | - | IsUserGesture: true, | |
| 501 | - | NoJavaScriptAccess: true, | |
| 502 | - | }) | |
| 503 | - | ||
| 504 | - | require.Same(t, firstWV, first) | |
| 505 | - | require.Same(t, secondWV, second) | |
| 506 | - | assert.Equal(t, 2, insertCalls) | |
| 507 | - | } | |
| 508 | - | ||
| 509 | - | func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) { | |
| 510 | - | ctx := context.Background() | |
| 511 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 512 | - | parentWV := mocks.NewMockWebView(t) | |
| 513 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice() | |
| 514 | - | ||
| 515 | - | popupWV := mocks.NewMockWebView(t) | |
| 516 | - | popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 517 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 518 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 519 | - | popupWV.EXPECT().IsLoading().Return(false).Maybe() | |
| 520 | - | currentURI := "" | |
| 521 | - | popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe() | |
| 522 | - | popupWV.EXPECT().IsDestroyed().Return(false).Maybe() | |
| 523 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error { | |
| 524 | - | currentURI = "https://example.com/first" | |
| 525 | - | return nil | |
| 526 | - | }).Once() | |
| 527 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error { | |
| 528 | - | currentURI = "https://example.com/second" | |
| 529 | - | return nil | |
| 530 | - | }).Once() | |
| 531 | - | ||
| 532 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 533 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 534 | - | ||
| 535 | - | insertCalls := 0 | |
| 536 | - | c := &Coordinator{ | |
| 537 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 538 | - | popups: newPopupManager(), | |
| 539 | - | } | |
| 540 | - | c.SetPopupConfig(factory, nil, nil) | |
| 541 | - | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 542 | - | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 543 | - | insertCalls++ | |
| 544 | - | return nil | |
| 545 | - | }) | |
| 546 | - | ||
| 547 | - | first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 548 | - | TargetURI: "https://example.com/first", | |
| 549 | - | FrameName: "shared-pane", | |
| 550 | - | IsUserGesture: true, | |
| 551 | - | }) | |
| 552 | - | require.Same(t, popupWV, first) | |
| 553 | - | ||
| 554 | - | second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 555 | - | TargetURI: "https://example.com/second", | |
| 556 | - | FrameName: "shared-pane", | |
| 557 | - | IsUserGesture: true, | |
| 558 | - | }) | |
| 559 | - | require.Same(t, popupWV, second) | |
| 560 | - | ||
| 561 | - | assert.Equal(t, 1, insertCalls) | |
| 562 | - | assert.Equal(t, "https://example.com/second", currentURI) | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t *testing.T) { | |
| 566 | - | ctx := context.Background() | |
| 567 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 568 | - | stalePaneID := entity.PaneID("stale-pane") | |
| 569 | - | parentWV := mocks.NewMockWebView(t) | |
| 570 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 571 | - | ||
| 572 | - | staleWV := mocks.NewMockWebView(t) | |
| 573 | - | staleWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 574 | - | staleWV.EXPECT().ID().Return(port.WebViewID(202)).Once() | |
| 575 | - | staleWV.EXPECT().IsDestroyed().Return(true).Once() | |
| 576 | - | ||
| 577 | - | newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 578 | - | newWV.EXPECT().ID().Return(port.WebViewID(303)).Maybe() | |
| 579 | - | newWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 580 | - | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 581 | - | ||
| 582 | - | factory := mocks.NewMockWebViewFactory(t) | |
| 583 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Once() | |
| 584 | - | ||
| 585 | - | insertCalls := 0 | |
| 586 | - | c := &Coordinator{ | |
| 587 | - | webViews: map[entity.PaneID]port.WebView{ | |
| 588 | - | stalePaneID: staleWV, | |
| 589 | - | }, | |
| 590 | - | popups: newPopupManager(), | |
| 591 | - | } | |
| 592 | - | c.SetPopupConfig(factory, nil, nil) | |
| 593 | - | c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true }) | |
| 594 | - | c.popups.namedContexts.Register("window-1", "shared-pane", stalePaneID, port.WebViewID(202)) | |
| 595 | - | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 596 | - | insertCalls++ | |
| 597 | - | return nil | |
| 598 | - | }) | |
| 599 | - | ||
| 600 | - | created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 601 | - | TargetURI: "https://example.com/replacement", | |
| 602 | - | FrameName: "shared-pane", | |
| 603 | - | IsUserGesture: true, | |
| 604 | - | }) | |
| 605 | - | ||
| 606 | - | require.Same(t, newWV, created) | |
| 607 | - | assert.Equal(t, 1, insertCalls) | |
| 608 | - | decision, hasDecision := newWV.BrowsingContextHostDecision() | |
| 609 | - | require.True(t, hasDecision) | |
| 610 | - | assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind) | |
| 611 | - | assert.Empty(t, decision.ReuseContextName) | |
| 612 | - | assert.Equal(t, "shared-pane", decision.BrowsingContextName) | |
| 613 | - | } | |
| 614 | - | ||
| 615 | - | // --------------------------------------------------------------------------- | |
| 616 | - | // trackOAuthPopup / capturePopupOAuthState | |
| 617 | - | // --------------------------------------------------------------------------- | |
| 618 | - | ||
| 619 | - | func TestTrackOAuthPopup_StoresState(t *testing.T) { | |
| 620 | - | t.Parallel() | |
| 621 | - | ||
| 622 | - | c := &Coordinator{ | |
| 623 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 624 | - | popups: newPopupManager(), | |
| 625 | - | } | |
| 626 | - | ||
| 627 | - | popupID := port.WebViewID(1) | |
| 628 | - | parentPaneID := entity.PaneID("parent-pane") | |
| 629 | - | ||
| 630 | - | c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login") | |
| 631 | - | ||
| 632 | - | c.popups.mu.RLock() | |
| 633 | - | state, ok := c.popups.popupOAuth[popupID] | |
| 634 | - | c.popups.mu.RUnlock() | |
| 635 | - | ||
| 636 | - | require.True(t, ok, "oauth state should be tracked after trackOAuthPopup") | |
| 637 | - | require.NotNil(t, state) | |
| 638 | - | assert.Equal(t, parentPaneID, state.ParentPaneID) | |
| 639 | - | assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen) | |
| 640 | - | assert.False(t, state.Seen, "Seen should be false before capturing callback URI") | |
| 641 | - | } | |
| 642 | - | ||
| 643 | - | func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) { | |
| 644 | - | t.Parallel() | |
| 645 | - | ||
| 646 | - | c := &Coordinator{ | |
| 647 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 648 | - | popups: newPopupManager(), | |
| 649 | - | } | |
| 650 | - | ||
| 651 | - | popupID := port.WebViewID(2) | |
| 652 | - | c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login") | |
| 653 | - | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123") | |
| 654 | - | ||
| 655 | - | c.popups.mu.RLock() | |
| 656 | - | state := c.popups.popupOAuth[popupID] | |
| 657 | - | c.popups.mu.RUnlock() | |
| 658 | - | ||
| 659 | - | require.NotNil(t, state) | |
| 660 | - | assert.True(t, state.Seen) | |
| 661 | - | assert.True(t, state.Success) | |
| 662 | - | assert.False(t, state.Error) | |
| 663 | - | assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI) | |
| 664 | - | } | |
| 665 | - | ||
| 666 | - | func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) { | |
| 667 | - | t.Parallel() | |
| 668 | - | ||
| 669 | - | c := &Coordinator{ | |
| 670 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 671 | - | popups: newPopupManager(), | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | popupID := port.WebViewID(3) | |
| 675 | - | c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login") | |
| 676 | - | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied") | |
| 677 | - | ||
| 678 | - | c.popups.mu.RLock() | |
| 679 | - | state := c.popups.popupOAuth[popupID] | |
| 680 | - | c.popups.mu.RUnlock() | |
| 681 | - | ||
| 682 | - | require.NotNil(t, state) | |
| 683 | - | assert.True(t, state.Seen) | |
| 684 | - | assert.False(t, state.Success) | |
| 685 | - | assert.True(t, state.Error) | |
| 686 | - | } | |
| 687 | - | ||
| 688 | - | func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) { | |
| 689 | - | t.Parallel() | |
| 690 | - | ||
| 691 | - | c := &Coordinator{ | |
| 692 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 693 | - | popups: newPopupManager(), | |
| 694 | - | } | |
| 695 | - | ||
| 696 | - | // Should not panic when no tracking entry exists. | |
| 697 | - | c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x") | |
| 698 | - | ||
| 699 | - | c.popups.mu.RLock() | |
| 700 | - | _, ok := c.popups.popupOAuth[port.WebViewID(999)] | |
| 701 | - | c.popups.mu.RUnlock() | |
| 702 | - | ||
| 703 | - | assert.False(t, ok) | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | // --------------------------------------------------------------------------- | |
| 707 | - | // SetPopupConfig | |
| 708 | - | // --------------------------------------------------------------------------- | |
| 709 | - | ||
| 710 | - | func TestSetPopupConfig_SetsFields(t *testing.T) { | |
| 711 | - | t.Parallel() | |
| 712 | - | ||
| 713 | - | c := &Coordinator{} | |
| 714 | - | ||
| 715 | - | factory := &stubFactory{} | |
| 716 | - | cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit} | |
| 717 | - | genID := func() string { return "test-id" } | |
| 718 | - | ||
| 719 | - | c.SetPopupConfig(factory, cfg, genID) | |
| 720 | - | ||
| 721 | - | assert.Equal(t, factory, c.popups.factory) | |
| 722 | - | assert.True(t, c.popups.hasPopupConfig) | |
| 723 | - | assert.Equal(t, *cfg, c.popups.popupConfig) | |
| 724 | - | cfg.Behavior = entity.PopupBehaviorWindowed | |
| 725 | - | assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior) | |
| 726 | - | assert.NotNil(t, c.popups.generatePaneID) | |
| 727 | - | assert.Equal(t, "test-id", c.popups.generatePaneID()) | |
| 728 | - | } | |
| 729 | - | ||
| 730 | - | func TestSetPopupConfig_NilConfigAllowed(t *testing.T) { | |
| 731 | - | t.Parallel() | |
| 732 | - | ||
| 733 | - | c := &Coordinator{} | |
| 734 | - | c.SetPopupConfig(nil, nil, nil) | |
| 735 | - | ||
| 736 | - | assert.Nil(t, c.popups.factory) | |
| 737 | - | assert.False(t, c.popups.hasPopupConfig) | |
| 738 | - | assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig) | |
| 739 | - | assert.Nil(t, c.popups.generatePaneID) | |
| 740 | - | } | |
| 741 | - | ||
| 742 | - | func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) { | |
| 743 | - | t.Parallel() | |
| 744 | - | ||
| 745 | - | c := &Coordinator{} | |
| 746 | - | initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false} | |
| 747 | - | c.SetPopupConfig(nil, initial, nil) | |
| 748 | - | ||
| 749 | - | c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true}) | |
| 750 | - | ||
| 751 | - | assert.True(t, c.popups.hasPopupConfig) | |
| 752 | - | assert.False(t, c.popups.popupConfig.OpenInNewPane) | |
| 753 | - | assert.True(t, c.popups.popupConfig.OAuthAutoClose) | |
| 754 | - | assert.True(t, initial.OpenInNewPane) | |
| 755 | - | assert.False(t, initial.OAuthAutoClose) | |
| 756 | - | } | |
| 757 | - | ||
| 758 | - | type popupNavigationWebViewStub struct { | |
| 759 | - | *mocks.MockWebView | |
| 760 | - | primed []string | |
| 761 | - | browsingContextDecision dto.HostDecision | |
| 762 | - | hasBrowsingContextDecision bool | |
| 763 | - | } | |
| 764 | - | ||
| 765 | - | func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) { | |
| 766 | - | s.primed = append(s.primed, uri) | |
| 767 | - | } | |
| 768 | - | ||
| 769 | - | func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 770 | - | s.browsingContextDecision = decision | |
| 771 | - | s.hasBrowsingContextDecision = true | |
| 772 | - | } | |
| 773 | - | ||
| 774 | - | func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) { | |
| 775 | - | return s.browsingContextDecision, s.hasBrowsingContextDecision | |
| 776 | - | } | |
| 777 | - | ||
| 778 | - | func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {} | |
| 779 | - | func (*popupNavigationWebViewStub) SetOnClose(func()) {} | |
| 780 | - | func (*popupNavigationWebViewStub) Show() {} | |
| 781 | - | ||
| 782 | - | // stubFactory satisfies port.WebViewFactory without importing the mocks package. | |
| 783 | - | type stubFactory struct{} | |
| 784 | - | ||
| 785 | - | func (s *stubFactory) Create(_ context.Context) (port.WebView, error) { | |
| 786 | - | return nil, nil | |
| 787 | - | } | |
| 788 | - | ||
| 789 | - | func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) { | |
| 790 | - | return nil, nil | |
| 791 | - | } | |
| 792 | - | ||
| 793 | - | // --------------------------------------------------------------------------- | |
| 794 | - | // Concurrent access to pendingPopups | |
| 795 | - | // --------------------------------------------------------------------------- | |
| 796 | - | ||
| 797 | - | func TestPendingPopups_ConcurrentAccess(t *testing.T) { | |
| 798 | - | t.Parallel() | |
| 799 | - | ||
| 800 | - | c := &Coordinator{ | |
| 801 | - | popups: newPopupManager(), | |
| 802 | - | } | |
| 803 | - | ||
| 804 | - | const workers = 20 | |
| 805 | - | var wg sync.WaitGroup | |
| 806 | - | wg.Add(workers * 2) | |
| 807 | - | ||
| 808 | - | // Writers | |
| 809 | - | for i := range workers { | |
| 810 | - | go func(id int) { | |
| 811 | - | defer wg.Done() | |
| 812 | - | popupID := port.WebViewID(id) | |
| 813 | - | c.popups.mu.Lock() | |
| 814 | - | c.popups.pendingPopups[popupID] = &PendingPopup{ | |
| 815 | - | FrameName: "_blank", | |
| 816 | - | PopupType: PopupTypeTab, | |
| 817 | - | } | |
| 818 | - | c.popups.mu.Unlock() | |
| 819 | - | }(i) | |
| 820 | - | } | |
| 821 | - | ||
| 822 | - | // Readers | |
| 823 | - | for i := range workers { | |
| 824 | - | go func(id int) { | |
| 825 | - | defer wg.Done() | |
| 826 | - | popupID := port.WebViewID(id) | |
| 827 | - | c.popups.mu.RLock() | |
| 828 | - | _ = c.popups.pendingPopups[popupID] | |
| 829 | - | c.popups.mu.RUnlock() | |
| 830 | - | }(i) | |
| 831 | - | } | |
| 832 | - | ||
| 833 | - | wg.Wait() | |
| 834 | - | ||
| 835 | - | c.popups.mu.RLock() | |
| 836 | - | count := len(c.popups.pendingPopups) | |
| 837 | - | c.popups.mu.RUnlock() | |
| 838 | - | ||
| 839 | - | assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present") | |
| 840 | - | } | |
| 841 | - | ||
| 842 | - | func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |
| 843 | - | t.Parallel() | |
| 844 | - | ||
| 845 | - | c := &Coordinator{ | |
| 846 | - | popups: newPopupManager(), | |
| 847 | - | } | |
| 848 | - | ||
| 849 | - | // Pre-populate | |
| 850 | - | for i := range 10 { | |
| 851 | - | c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup} | |
| 852 | - | } | |
| 853 | - | ||
| 854 | - | var wg sync.WaitGroup | |
| 855 | - | wg.Add(20) | |
| 856 | - | ||
| 857 | - | // Deleters | |
| 858 | - | for i := range 10 { | |
| 859 | - | go func(id int) { | |
| 860 | - | defer wg.Done() | |
| 861 | - | c.popups.mu.Lock() | |
| 862 | - | delete(c.popups.pendingPopups, port.WebViewID(id)) | |
| 863 | - | c.popups.mu.Unlock() | |
| 864 | - | }(i) | |
| 865 | - | } | |
| 866 | - | ||
| 867 | - | // Readers | |
| 868 | - | for i := range 10 { | |
| 869 | - | go func(id int) { | |
| 870 | - | defer wg.Done() | |
| 871 | - | c.popups.mu.RLock() | |
| 872 | - | _ = c.popups.pendingPopups[port.WebViewID(id)] | |
| 873 | - | c.popups.mu.RUnlock() | |
| 874 | - | }(i) | |
| 875 | - | } | |
| 876 | - | ||
| 877 | - | wg.Wait() | |
| 878 | - | ||
| 879 | - | c.popups.mu.RLock() | |
| 880 | - | defer c.popups.mu.RUnlock() | |
| 881 | - | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | |
| 882 | - | } | |
M internal/ui/coordinator/tab.go +16 -5
| @@ -35,8 +35,8 @@ type TabCoordinator struct { | |||
| 35 | 35 | onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 36 | 36 | onQuit func() | |
| 37 | 37 | onCurrentWindowEmpty func(ctx context.Context, target TabTarget) | |
| 38 | - | onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs | |
| 39 | - | onStateChanged func() // For session snapshots | |
| 38 | + | onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs | |
| 39 | + | onStateChanged func() // For session snapshots | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| @@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) { | |||
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | |
| 135 | - | func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) { | |
| 135 | + | func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error) { | |
| 136 | 136 | c.onAttachPopupToTab = fn | |
| 137 | 137 | } | |
| 138 | 138 | ||
| @@ -540,9 +540,20 @@ func (c *TabCoordinator) CreateWithPane( | |||
| 540 | 540 | // Update tab bar visibility | |
| 541 | 541 | c.UpdateBarVisibility(ctx, target) | |
| 542 | 542 | ||
| 543 | - | // Attach the popup WebView to the new tab's workspace | |
| 543 | + | // Attach the popup WebView to the new tab's workspace. The caller retains | |
| 544 | + | // WebView ownership until this succeeds. | |
| 544 | 545 | if c.onAttachPopupToTab != nil { | |
| 545 | - | c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv) | |
| 546 | + | if err := c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv); err != nil { | |
| 547 | + | target.Tabs.Remove(output.Tab.ID) | |
| 548 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 549 | + | target.MainWindow.TabBar().RemoveTab(output.Tab.ID) | |
| 550 | + | } | |
| 551 | + | c.UpdateBarVisibility(ctx, target) | |
| 552 | + | if c.onTabClosed != nil { | |
| 553 | + | c.onTabClosed(ctx, target, output.Tab) | |
| 554 | + | } | |
| 555 | + | return nil, fmt.Errorf("attach popup webview: %w", err) | |
| 556 | + | } | |
| 546 | 557 | } | |
| 547 | 558 | ||
| 548 | 559 | // Switch to the new tab's workspace view | |
M internal/ui/floating_pane_test.go +30 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 10 | 11 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 11 | 12 | "github.com/stretchr/testify/assert" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -312,6 +313,35 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) { | |||
| 312 | 313 | assert.False(t, handled) | |
| 313 | 314 | } | |
| 314 | 315 | ||
| 316 | + | func TestFloatingPane_HideAndCloseLeaveDetachedBrowserWindowAlive(t *testing.T) { | |
| 317 | + | ctx := context.Background() | |
| 318 | + | app, tabID, session := newFloatingPaneTestApp(t) | |
| 319 | + | pool := &recordingWebViewPool{} | |
| 320 | + | contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil) | |
| 321 | + | sourceWebView := &recordingWebView{id: 301} | |
| 322 | + | detachedWebView := &recordingWebView{id: 302} | |
| 323 | + | detachedPaneID := entity.PaneID("detached-pane") | |
| 324 | + | contentCoord.RegisterPopupWebView(session.paneID, sourceWebView) | |
| 325 | + | contentCoord.RegisterPopupWebView(detachedPaneID, detachedWebView) | |
| 326 | + | app.contentCoord = contentCoord | |
| 327 | + | detachedWindow := &browserWindow{id: "detached-window", tabs: entity.NewTabList()} | |
| 328 | + | app.browserWindows[detachedWindow.id] = detachedWindow | |
| 329 | + | ||
| 330 | + | require.NoError(t, session.pane.ShowURL(ctx, "https://example.com/source")) | |
| 331 | + | app.hideFloatingSession(ctx, session) | |
| 332 | + | assert.Same(t, detachedWebView, contentCoord.GetWebView(detachedPaneID)) | |
| 333 | + | assert.Same(t, detachedWindow, app.browserWindows[detachedWindow.id]) | |
| 334 | + | ||
| 335 | + | key := floatingSessionKey{tabID: tabID, sessionID: floatingSessionIDDefault} | |
| 336 | + | app.releaseFloatingSession(ctx, key, session) | |
| 337 | + | ||
| 338 | + | assert.Same(t, detachedWebView, contentCoord.GetWebView(detachedPaneID)) | |
| 339 | + | assert.Same(t, detachedWindow, app.browserWindows[detachedWindow.id]) | |
| 340 | + | assert.Zero(t, detachedWebView.destroyCalls) | |
| 341 | + | require.Len(t, pool.released, 1) | |
| 342 | + | assert.Same(t, sourceWebView, pool.released[0]) | |
| 343 | + | } | |
| 344 | + | ||
| 315 | 345 | func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) { | |
| 316 | 346 | app, _, session := newFloatingPaneTestApp(t) | |
| 317 | 347 | ||
M internal/ui/native_popup_window.go +50 -25
| @@ -14,25 +14,32 @@ import ( | |||
| 14 | 14 | "github.com/bnema/puregotk/v4/gtk" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | type nativePopupShell interface { | |
| 18 | + | DetachContent() *gtk.Widget | |
| 19 | + | Destroy() | |
| 20 | + | Close() | |
| 21 | + | Show() | |
| 22 | + | } | |
| 23 | + | ||
| 17 | 24 | type nativePopupWindow struct { | |
| 18 | 25 | popupID port.WebViewID | |
| 19 | 26 | parentPaneID entity.PaneID | |
| 20 | 27 | parentWindowID string | |
| 21 | - | popupWindow *window.PopupWindow | |
| 28 | + | popupWindow nativePopupShell | |
| 22 | 29 | webView port.WebView | |
| 23 | 30 | closeOnce sync.Once | |
| 24 | 31 | } | |
| 25 | 32 | ||
| 26 | - | type nativePopupDestroyer interface { | |
| 27 | - | Destroy() | |
| 28 | - | } | |
| 33 | + | type nativePopupReleaseMode int | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | nativePopupReleaseDestroy nativePopupReleaseMode = iota | |
| 37 | + | nativePopupReleaseDetach | |
| 38 | + | ) | |
| 29 | 39 | ||
| 30 | 40 | var newPopupWindow = window.NewPopup | |
| 31 | 41 | ||
| 32 | - | func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) { | |
| 33 | - | if wv != nil && !wv.IsDestroyed() { | |
| 34 | - | wv.Destroy() | |
| 35 | - | } | |
| 42 | + | func destroyFailedNativePopupSetup(popupShell nativePopupShell) { | |
| 36 | 43 | if popupShell != nil { | |
| 37 | 44 | popupShell.Destroy() | |
| 38 | 45 | } | |
| @@ -67,17 +74,16 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 67 | 74 | ||
| 68 | 75 | popupShell, err := newPopupWindow(ctx, a.gtkApp) | |
| 69 | 76 | if err != nil { | |
| 70 | - | destroyFailedNativePopupSetup(nil, input.PopupWebView) | |
| 71 | 77 | return err | |
| 72 | 78 | } | |
| 73 | 79 | if a.contentCoord == nil { | |
| 74 | - | destroyFailedNativePopupSetup(popupShell, input.PopupWebView) | |
| 80 | + | destroyFailedNativePopupSetup(popupShell) | |
| 75 | 81 | return fmt.Errorf("content coordinator not available for native popup") | |
| 76 | 82 | } | |
| 77 | 83 | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | |
| 78 | 84 | gtkWidget, err := prepareNativePopupContentWidget(widget) | |
| 79 | 85 | if err != nil { | |
| 80 | - | destroyFailedNativePopupSetup(popupShell, input.PopupWebView) | |
| 86 | + | destroyFailedNativePopupSetup(popupShell) | |
| 81 | 87 | return err | |
| 82 | 88 | } | |
| 83 | 89 | popupShell.SetContent(gtkWidget) | |
| @@ -102,7 +108,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 102 | 108 | ||
| 103 | 109 | if popupShell.Window() != nil { | |
| 104 | 110 | closeRequestCb := func(_ gtk.Window) bool { | |
| 105 | - | a.releaseNativePopupWindow(popupID, false, false) | |
| 111 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) | |
| 106 | 112 | return false | |
| 107 | 113 | } | |
| 108 | 114 | popupShell.Window().ConnectCloseRequest(&closeRequestCb) | |
| @@ -111,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 111 | 117 | if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok { | |
| 112 | 118 | aborter.SetNativePopupHostAbort(func() { | |
| 113 | 119 | a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() { | |
| 114 | - | a.releaseNativePopupWindow(popupID, true, false) | |
| 120 | + | a.abortNativePopupWindow(context.Background(), popupID, input) | |
| 115 | 121 | }) | |
| 116 | 122 | }) | |
| 117 | 123 | } | |
| @@ -123,7 +129,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 123 | 129 | }) | |
| 124 | 130 | lifecycle.SetOnClose(func() { | |
| 125 | 131 | a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() { | |
| 126 | - | a.releaseNativePopupWindow(popupID, true, false) | |
| 132 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) | |
| 127 | 133 | }) | |
| 128 | 134 | }) | |
| 129 | 135 | } else { | |
| @@ -132,7 +138,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 132 | 138 | if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok { | |
| 133 | 139 | oauthWV.AddCloseCallback(func() { | |
| 134 | 140 | a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() { | |
| 135 | - | a.releaseNativePopupWindow(popupID, true, false) | |
| 141 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) | |
| 136 | 142 | }) | |
| 137 | 143 | }) | |
| 138 | 144 | } | |
| @@ -148,6 +154,22 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||
| 148 | 154 | return nil | |
| 149 | 155 | } | |
| 150 | 156 | ||
| 157 | + | func (a *App) abortNativePopupWindow(ctx context.Context, popupID port.WebViewID, input content.NativePopupInput) { | |
| 158 | + | wv := a.releaseNativePopupWindow(popupID, nativePopupReleaseDetach) | |
| 159 | + | if wv == nil { | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | transferred := false | |
| 163 | + | if input.AllowBrowserWindowFallback && input.OnNativeHostAbort != nil { | |
| 164 | + | transferred = input.OnNativeHostAbort(ctx, wv) | |
| 165 | + | } else if input.OnNativeHostAbort != nil { | |
| 166 | + | input.OnNativeHostAbort(ctx, wv) | |
| 167 | + | } | |
| 168 | + | if !transferred && !wv.IsDestroyed() { | |
| 169 | + | wv.Destroy() | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 151 | 173 | func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) { | |
| 152 | 174 | if a == nil || fn == nil { | |
| 153 | 175 | return | |
| @@ -181,26 +203,29 @@ func (a *App) showNativePopupWindow(popupID port.WebViewID) { | |||
| 181 | 203 | } | |
| 182 | 204 | } | |
| 183 | 205 | ||
| 184 | - | func (a *App) releaseNativePopupWindow(popupID port.WebViewID, closeWindow, destroyWindow bool) { | |
| 206 | + | func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView { | |
| 185 | 207 | if a == nil || a.nativePopupWindows == nil { | |
| 186 | - | return | |
| 208 | + | return nil | |
| 187 | 209 | } | |
| 188 | 210 | state := a.nativePopupWindows[popupID] | |
| 189 | 211 | if state == nil { | |
| 190 | - | return | |
| 212 | + | return nil | |
| 191 | 213 | } | |
| 214 | + | var detached port.WebView | |
| 192 | 215 | state.closeOnce.Do(func() { | |
| 193 | 216 | delete(a.nativePopupWindows, popupID) | |
| 194 | - | if state.webView != nil && !state.webView.IsDestroyed() { | |
| 217 | + | if mode == nativePopupReleaseDetach { | |
| 218 | + | if state.popupWindow != nil && state.popupWindow.DetachContent() != nil { | |
| 219 | + | detached = state.webView | |
| 220 | + | } else if state.webView != nil && !state.webView.IsDestroyed() { | |
| 221 | + | state.webView.Destroy() | |
| 222 | + | } | |
| 223 | + | } else if state.webView != nil && !state.webView.IsDestroyed() { | |
| 195 | 224 | state.webView.Destroy() | |
| 196 | 225 | } | |
| 197 | 226 | if state.popupWindow != nil { | |
| 198 | - | switch { | |
| 199 | - | case destroyWindow: | |
| 200 | - | state.popupWindow.Destroy() | |
| 201 | - | case closeWindow: | |
| 202 | - | state.popupWindow.Close() | |
| 203 | - | } | |
| 227 | + | state.popupWindow.Destroy() | |
| 204 | 228 | } | |
| 205 | 229 | }) | |
| 230 | + | return detached | |
| 206 | 231 | } | |
M internal/ui/native_popup_window_test.go +67 -23
| @@ -18,12 +18,16 @@ import ( | |||
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | type popupDestroySpy struct { | |
| 21 | - | destroyed bool | |
| 21 | + | destroyed int | |
| 22 | + | detached int | |
| 23 | + | closed int | |
| 24 | + | shown int | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 24 | - | func (p *popupDestroySpy) Destroy() { | |
| 25 | - | p.destroyed = true | |
| 26 | - | } | |
| 27 | + | func (p *popupDestroySpy) Destroy() { p.destroyed++ } | |
| 28 | + | func (p *popupDestroySpy) DetachContent() *gtk.Widget { p.detached++; return >k.Widget{} } | |
| 29 | + | func (p *popupDestroySpy) Close() { p.closed++ } | |
| 30 | + | func (p *popupDestroySpy) Show() { p.shown++ } | |
| 27 | 31 | ||
| 28 | 32 | func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) { | |
| 29 | 33 | widget := layoutmocks.NewMockWidget(t) | |
| @@ -46,7 +50,7 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te | |||
| 46 | 50 | assert.Nil(t, got) | |
| 47 | 51 | } | |
| 48 | 52 | ||
| 49 | - | func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) { | |
| 53 | + | func TestOpenNativePopupWindowLeavesWebViewOwnedByCallerOnSetupFailure(t *testing.T) { | |
| 50 | 54 | oldNewPopupWindow := newPopupWindow | |
| 51 | 55 | t.Cleanup(func() { | |
| 52 | 56 | newPopupWindow = oldNewPopupWindow | |
| @@ -58,23 +62,15 @@ func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *tes | |||
| 58 | 62 | } | |
| 59 | 63 | ||
| 60 | 64 | wv := portmocks.NewMockWebView(t) | |
| 61 | - | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| 62 | - | wv.EXPECT().Destroy().Once() | |
| 63 | - | ||
| 64 | 65 | app := &App{gtkApp: >k.Application{}} | |
| 65 | 66 | err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv}) | |
| 66 | 67 | require.ErrorIs(t, err, expectedErr) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | - | func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) { | |
| 70 | - | wv := portmocks.NewMockWebView(t) | |
| 71 | - | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| 72 | - | wv.EXPECT().Destroy().Once() | |
| 73 | - | ||
| 70 | + | func TestDestroyFailedNativePopupSetupDestroysOnlyShell(t *testing.T) { | |
| 74 | 71 | shell := &popupDestroySpy{} | |
| 75 | - | destroyFailedNativePopupSetup(shell, wv) | |
| 76 | - | ||
| 77 | - | assert.True(t, shell.destroyed) | |
| 72 | + | destroyFailedNativePopupSetup(shell) | |
| 73 | + | assert.Equal(t, 1, shell.destroyed) | |
| 78 | 74 | } | |
| 79 | 75 | ||
| 80 | 76 | func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) { | |
| @@ -104,19 +100,67 @@ func TestDispatchNativePopupLifecycleTreatsCompletedAfterTimeoutAsCompleted(t *t | |||
| 104 | 100 | assert.True(t, ran) | |
| 105 | 101 | } | |
| 106 | 102 | ||
| 107 | - | func TestReleaseNativePopupWindow_DestroysWebViewAndRemovesState(t *testing.T) { | |
| 103 | + | func TestDetachNativePopupWindowRemovesWidgetWithoutDestroyingWebView(t *testing.T) { | |
| 104 | + | wv := portmocks.NewMockWebView(t) | |
| 105 | + | shell := &popupDestroySpy{} | |
| 106 | + | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | |
| 107 | + | 1: {popupID: 1, webView: wv, popupWindow: shell}, | |
| 108 | + | }} | |
| 109 | + | ||
| 110 | + | detached := app.releaseNativePopupWindow(1, nativePopupReleaseDetach) | |
| 111 | + | assert.Same(t, wv, detached) | |
| 112 | + | assert.Equal(t, 1, shell.detached) | |
| 113 | + | assert.Equal(t, 1, shell.destroyed) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestNativePopupAbortTransfersEligibleWebViewToBrowserFallback(t *testing.T) { | |
| 117 | + | wv := portmocks.NewMockWebView(t) | |
| 118 | + | shell := &popupDestroySpy{} | |
| 119 | + | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | |
| 120 | + | 1: {popupID: 1, webView: wv, popupWindow: shell}, | |
| 121 | + | }} | |
| 122 | + | callbackCalls := 0 | |
| 123 | + | app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{ | |
| 124 | + | AllowBrowserWindowFallback: true, | |
| 125 | + | OnNativeHostAbort: func(_ context.Context, got port.WebView) bool { | |
| 126 | + | callbackCalls++ | |
| 127 | + | assert.Same(t, wv, got) | |
| 128 | + | assert.Equal(t, 1, shell.detached, "widget must detach before fallback adoption") | |
| 129 | + | return true | |
| 130 | + | }, | |
| 131 | + | }) | |
| 132 | + | ||
| 133 | + | assert.Equal(t, 1, callbackCalls) | |
| 134 | + | assert.Equal(t, 1, shell.destroyed) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestNativePopupAbortDestroysOpenerRequiredWebView(t *testing.T) { | |
| 108 | 138 | wv := portmocks.NewMockWebView(t) | |
| 109 | 139 | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| 110 | 140 | wv.EXPECT().Destroy().Once() | |
| 141 | + | shell := &popupDestroySpy{} | |
| 142 | + | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | |
| 143 | + | 1: {popupID: 1, webView: wv, popupWindow: shell}, | |
| 144 | + | }} | |
| 145 | + | app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{ | |
| 146 | + | AllowBrowserWindowFallback: false, | |
| 147 | + | OnNativeHostAbort: func(context.Context, port.WebView) bool { return false }, | |
| 148 | + | }) | |
| 149 | + | assert.Equal(t, 1, shell.detached) | |
| 150 | + | } | |
| 111 | 151 | ||
| 152 | + | func TestReleaseNativePopupWindowDestroysOnlyOnce(t *testing.T) { | |
| 153 | + | wv := portmocks.NewMockWebView(t) | |
| 154 | + | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| 155 | + | wv.EXPECT().Destroy().Once() | |
| 156 | + | shell := &popupDestroySpy{} | |
| 112 | 157 | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | |
| 113 | - | port.WebViewID(1): { | |
| 114 | - | popupID: port.WebViewID(1), | |
| 115 | - | webView: wv, | |
| 116 | - | }, | |
| 158 | + | 1: {popupID: 1, webView: wv, popupWindow: shell}, | |
| 117 | 159 | }} | |
| 118 | 160 | ||
| 119 | - | app.releaseNativePopupWindow(port.WebViewID(1), false, false) | |
| 120 | - | _, ok := app.nativePopupWindows[port.WebViewID(1)] | |
| 161 | + | app.releaseNativePopupWindow(1, nativePopupReleaseDestroy) | |
| 162 | + | app.releaseNativePopupWindow(1, nativePopupReleaseDestroy) | |
| 163 | + | _, ok := app.nativePopupWindows[1] | |
| 121 | 164 | assert.False(t, ok) | |
| 165 | + | assert.Equal(t, 1, shell.destroyed) | |
| 122 | 166 | } | |
A internal/ui/popup_staging_host.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 9 | + | "github.com/bnema/dumber/internal/ui/window" | |
| 10 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type popupStagingShell interface { | |
| 14 | + | SetContent(*gtk.Widget) | |
| 15 | + | DetachContent() *gtk.Widget | |
| 16 | + | Destroy() | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type popupStagingHost struct { | |
| 20 | + | shell popupStagingShell | |
| 21 | + | attached *gtk.Widget | |
| 22 | + | once sync.Once | |
| 23 | + | cleanupErr error | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var newPopupStagingWindow = func(ctx context.Context, app *gtk.Application) (popupStagingShell, error) { | |
| 27 | + | return window.NewPopup(ctx, app) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var preparePopupStagingContentWidget = prepareNativePopupContentWidget | |
| 31 | + | ||
| 32 | + | func (h *popupStagingHost) cleanup() { | |
| 33 | + | if h == nil { | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | h.once.Do(func() { | |
| 37 | + | if h.shell != nil { | |
| 38 | + | detached := h.shell.DetachContent() | |
| 39 | + | if h.attached != nil && detached == nil { | |
| 40 | + | h.cleanupErr = fmt.Errorf("popup staging content was not attached") | |
| 41 | + | } | |
| 42 | + | h.shell.Destroy() | |
| 43 | + | } | |
| 44 | + | h.attached = nil | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (h *popupStagingHost) Detach() error { | |
| 49 | + | h.cleanup() | |
| 50 | + | return h.cleanupErr | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (h *popupStagingHost) Destroy() { h.cleanup() } | |
| 54 | + | ||
| 55 | + | // stagePopup temporarily parents a WebKit popup widget in a hidden GTK shell. | |
| 56 | + | // The shell is deliberately never shown or presented. | |
| 57 | + | func (a *App) stagePopup(ctx context.Context, input content.StagePopupInput) (content.PopupStagingHost, error) { | |
| 58 | + | if a == nil || a.gtkApp == nil { | |
| 59 | + | return nil, fmt.Errorf("gtk application not available for popup staging") | |
| 60 | + | } | |
| 61 | + | if input.PopupWebView == nil { | |
| 62 | + | return nil, fmt.Errorf("popup staging webview is nil") | |
| 63 | + | } | |
| 64 | + | if a.contentCoord == nil { | |
| 65 | + | return nil, fmt.Errorf("content coordinator not available for popup staging") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | shell, err := newPopupStagingWindow(ctx, a.gtkApp) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | |
| 73 | + | gtkWidget, err := preparePopupStagingContentWidget(widget) | |
| 74 | + | if err != nil { | |
| 75 | + | shell.Destroy() | |
| 76 | + | return nil, fmt.Errorf("prepare popup staging webview widget: %w", err) | |
| 77 | + | } | |
| 78 | + | shell.SetContent(gtkWidget) | |
| 79 | + | return &popupStagingHost{shell: shell, attached: gtkWidget}, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | var _ content.PopupStagingHost = (*popupStagingHost)(nil) | |
A internal/ui/popup_staging_host_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 10 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 11 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type popupStagingShellSpy struct { | |
| 17 | + | detachCalls int | |
| 18 | + | destroyCalls int | |
| 19 | + | showCalls int | |
| 20 | + | content *gtk.Widget | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (s *popupStagingShellSpy) SetContent(widget *gtk.Widget) { s.content = widget } | |
| 24 | + | func (s *popupStagingShellSpy) DetachContent() *gtk.Widget { | |
| 25 | + | s.detachCalls++ | |
| 26 | + | widget := s.content | |
| 27 | + | s.content = nil | |
| 28 | + | return widget | |
| 29 | + | } | |
| 30 | + | func (s *popupStagingShellSpy) Destroy() { s.destroyCalls++ } | |
| 31 | + | ||
| 32 | + | func TestStagePopupWrapsContentPreparationErrorAndDestroysShell(t *testing.T) { | |
| 33 | + | prepareErr := errors.New("prepare failed") | |
| 34 | + | shell := &popupStagingShellSpy{} | |
| 35 | + | originalNewShell := newPopupStagingWindow | |
| 36 | + | originalPrepare := preparePopupStagingContentWidget | |
| 37 | + | t.Cleanup(func() { | |
| 38 | + | newPopupStagingWindow = originalNewShell | |
| 39 | + | preparePopupStagingContentWidget = originalPrepare | |
| 40 | + | }) | |
| 41 | + | newPopupStagingWindow = func(context.Context, *gtk.Application) (popupStagingShell, error) { | |
| 42 | + | return shell, nil | |
| 43 | + | } | |
| 44 | + | preparePopupStagingContentWidget = func(layout.Widget) (*gtk.Widget, error) { | |
| 45 | + | return nil, prepareErr | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | app := &App{ | |
| 49 | + | gtkApp: >k.Application{}, | |
| 50 | + | contentCoord: content.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil), | |
| 51 | + | } | |
| 52 | + | host, err := app.stagePopup(context.Background(), content.StagePopupInput{ | |
| 53 | + | PopupWebView: portmocks.NewMockWebView(t), | |
| 54 | + | }) | |
| 55 | + | ||
| 56 | + | require.Nil(t, host) | |
| 57 | + | require.ErrorIs(t, err, prepareErr) | |
| 58 | + | assert.Equal(t, 1, shell.destroyCalls) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestPopupStagingHostDetachRemovesContentThenDestroysShell(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | ||
| 64 | + | widget := >k.Widget{} | |
| 65 | + | shell := &popupStagingShellSpy{content: widget} | |
| 66 | + | host := &popupStagingHost{shell: shell, attached: widget} | |
| 67 | + | ||
| 68 | + | require.NoError(t, host.Detach()) | |
| 69 | + | assert.Equal(t, 1, shell.detachCalls) | |
| 70 | + | assert.Equal(t, 1, shell.destroyCalls) | |
| 71 | + | assert.Nil(t, shell.content) | |
| 72 | + | assert.Nil(t, host.attached) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestPopupStagingHostDestroyIsIdempotentAndNeverShows(t *testing.T) { | |
| 76 | + | t.Parallel() | |
| 77 | + | ||
| 78 | + | shell := &popupStagingShellSpy{content: >k.Widget{}} | |
| 79 | + | host := &popupStagingHost{shell: shell, attached: shell.content} | |
| 80 | + | ||
| 81 | + | host.Destroy() | |
| 82 | + | host.Destroy() | |
| 83 | + | require.NoError(t, host.Detach()) | |
| 84 | + | ||
| 85 | + | assert.Equal(t, 1, shell.detachCalls) | |
| 86 | + | assert.Equal(t, 1, shell.destroyCalls) | |
| 87 | + | assert.Zero(t, shell.showCalls) | |
| 88 | + | } | |
M internal/ui/window/popup_window.go +12 -0
| @@ -61,6 +61,18 @@ func (pw *PopupWindow) SetContent(widget *gtk.Widget) { | |||
| 61 | 61 | pw.currentContent = widget | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | // DetachContent removes and returns the hosted widget without destroying it. | |
| 65 | + | // Callers must detach before reparenting the widget into another GTK host. | |
| 66 | + | func (pw *PopupWindow) DetachContent() *gtk.Widget { | |
| 67 | + | if pw == nil || pw.content == nil || pw.currentContent == nil { | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | widget := pw.currentContent | |
| 71 | + | pw.content.Remove(widget) | |
| 72 | + | pw.currentContent = nil | |
| 73 | + | return widget | |
| 74 | + | } | |
| 75 | + | ||
| 64 | 76 | func (pw *PopupWindow) Show() { | |
| 65 | 77 | if pw == nil || pw.window == nil { | |
| 66 | 78 | return | |