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 | FaviconDatabase: {} | 85 | FaviconDatabase: {} | ||
| 86 | IdleInhibitor: {} | 86 | IdleInhibitor: {} | ||
| 87 | PopupLifecycleCapable: {} | 87 | PopupLifecycleCapable: {} | ||
| 88 | + | PopupFeatureResolver: {} | |||
| 88 | PopupOpenerCapable: {} | 89 | PopupOpenerCapable: {} | ||
| 89 | OAuthCallbackCapable: {} | 90 | OAuthCallbackCapable: {} | ||
| 90 | DevToolsOpener: {} | 91 | DevToolsOpener: {} | ||
M cmd/dumber/main.go +9 -0
| @@ -147,6 +147,15 @@ type startupTiming struct { | |||||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | func main() { | 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 | // CEF is not known until configuration is complete. Keep these neutral | 159 | // CEF is not known until configuration is complete. Keep these neutral | ||
| 151 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | 160 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | ||
| 152 | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | 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 | - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView. | 16 | - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView. | ||
| 17 | - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state. | 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 | ## Configuration | 25 | ## Configuration | ||
| 20 | 26 | ||||
| 21 | | Key | Type | Default | Description | | 27 | | Key | Type | Default | Description | | ||
M go.mod +2 -2
| @@ -6,8 +6,8 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1020 | 6 | github.com/a-h/templ v0.3.1020 | ||
| 7 | github.com/andybalholm/brotli v1.2.2 | 7 | github.com/andybalholm/brotli v1.2.2 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.4 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | ||
| 9 | - | github.com/bnema/purego-cef v0.14.1 | 9 | + | github.com/bnema/purego-cef v0.14.2 |
| 10 | - | github.com/bnema/purego-cef2gtk v0.8.5 | 10 | + | github.com/bnema/purego-cef2gtk v0.9.0 |
| 11 | github.com/bnema/purego-pipewire v0.1.5 | 11 | github.com/bnema/purego-pipewire v0.1.5 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.4 | 12 | github.com/bnema/purego-sqlite v0.1.4 | ||
| 13 | github.com/bnema/purego-webp v0.2.1 | 13 | github.com/bnema/purego-webp v0.2.1 | ||
M go.sum +4 -4 generated file: collapsed
M internal/application/dto/browsing_context.go +93 -24
| @@ -1,19 +1,56 @@ | |||||
| 1 | package dto | 1 | package dto | ||
| 2 | 2 | ||||
| 3 | - | // WindowDisposition describes how the browsing context should be presented | 3 | + | // SourceHostKind identifies the product host that originated a browsing-context request. |
| 4 | - | // relative to the source frame. Values mirror Chromium/CEF/WebKit navigation | 4 | + | type SourceHostKind string |
| 5 | - | // dispositions at a normalized product seam. | 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 | type WindowDisposition string | 47 | type WindowDisposition string | ||
| 7 | 48 | ||||
| 8 | const ( | 49 | const ( | ||
| 9 | - | // WindowDispositionCurrentTab replaces the current document in the same browsing context. | |||
| 10 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | 50 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | ||
| 11 | - | // WindowDispositionNewTab opens in a new tab-like context. | 51 | + | WindowDispositionNewTab WindowDisposition = "new-tab" |
| 12 | - | WindowDispositionNewTab WindowDisposition = "new-tab" | 52 | + | WindowDispositionNewPopup WindowDisposition = "new-popup" |
| 13 | - | // WindowDispositionNewPopup opens in a popup-style context. | 53 | + | WindowDispositionNewWindow WindowDisposition = "new-window" |
| 14 | - | WindowDispositionNewPopup WindowDisposition = "new-popup" | |||
| 15 | - | // WindowDispositionNewWindow opens in a new top-level window. | |||
| 16 | - | WindowDispositionNewWindow WindowDisposition = "new-window" | |||
| 17 | ) | 54 | ) | ||
| 18 | 55 | ||||
| 19 | // TriggerKind categorizes what triggered a browsing-context request. | 56 | // TriggerKind categorizes what triggered a browsing-context request. | ||
| @@ -27,22 +64,21 @@ const ( | |||||
| 27 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | 64 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | ||
| 28 | ) | 65 | ) | ||
| 29 | 66 | ||||
| 30 | - | // RequestContextDisposition makes request-context inheritance explicit at the | 67 | + | // RequestContextDisposition makes request-context inheritance explicit at the policy seam. |
| 31 | - | // policy seam without leaking infrastructure request-context types upward. | |||
| 32 | type RequestContextDisposition string | 68 | type RequestContextDisposition string | ||
| 33 | 69 | ||||
| 34 | const ( | 70 | const ( | ||
| 35 | - | // RequestContextInheritParent preserves the parent request context/session. | |||
| 36 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | 71 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | ||
| 37 | - | // RequestContextIsolate starts from a fresh unrelated request context. | 72 | + | RequestContextIsolate RequestContextDisposition = "isolate" |
| 38 | - | RequestContextIsolate RequestContextDisposition = "isolate" | |||
| 39 | ) | 73 | ) | ||
| 40 | 74 | ||||
| 41 | - | // NewBrowsingContextRequest is the normalized request captured from engine/UI | 75 | + | // NewBrowsingContextRequest is the normalized request captured before host policy runs. |
| 42 | - | // signals before policy decides how it should be hosted. | |||
| 43 | type NewBrowsingContextRequest struct { | 76 | type NewBrowsingContextRequest struct { | ||
| 44 | ParentWebViewID uint64 | 77 | ParentWebViewID uint64 | ||
| 45 | 78 | ||||
| 79 | + | Engine BrowserEngineKind | |||
| 80 | + | SourceHost SourceHostKind | |||
| 81 | + | ||||
| 46 | SourceBrowserID int32 | 82 | SourceBrowserID int32 | ||
| 47 | SourceFrameID string | 83 | SourceFrameID string | ||
| 48 | SourceFrameURL string | 84 | SourceFrameURL string | ||
| @@ -53,7 +89,7 @@ type NewBrowsingContextRequest struct { | |||||
| 53 | 89 | ||||
| 54 | IsUserGesture bool | 90 | IsUserGesture bool | ||
| 55 | NoJavaScriptAccess bool | 91 | NoJavaScriptAccess bool | ||
| 56 | - | WindowFeatures string | 92 | + | PopupFeatures PopupFeatures |
| 57 | 93 | ||||
| 58 | TriggerKind TriggerKind | 94 | TriggerKind TriggerKind | ||
| 59 | AuthIntent bool | 95 | AuthIntent bool | ||
| @@ -61,24 +97,57 @@ type NewBrowsingContextRequest struct { | |||||
| 61 | RequestContextDisposition RequestContextDisposition | 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 | type HostDecisionKind string | 101 | type HostDecisionKind string | ||
| 66 | 102 | ||||
| 67 | const ( | 103 | const ( | ||
| 68 | - | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | 104 | + | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" |
| 69 | - | HostDecisionCreatePane HostDecisionKind = "create-pane" | 105 | + | HostDecisionNavigateSource HostDecisionKind = "navigate-source" |
| 70 | - | HostDecisionCreateNativeWin HostDecisionKind = "create-native-window" | 106 | + | HostDecisionCreatePane HostDecisionKind = "create-pane" |
| 71 | - | HostDecisionDeny HostDecisionKind = "deny" | 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 | // HostDecision represents the output of browsing-context policy. | 141 | // HostDecision represents the output of browsing-context policy. | ||
| 75 | type HostDecision struct { | 142 | type HostDecision struct { | ||
| 76 | - | Kind HostDecisionKind | 143 | + | Kind HostDecisionKind |
| 144 | + | SourceHost SourceHostKind | |||
| 77 | 145 | ||||
| 78 | ReuseContextName string | 146 | ReuseContextName string | ||
| 79 | BrowsingContextName string | 147 | BrowsingContextName string | ||
| 80 | 148 | ||||
| 81 | RequestContextDisposition RequestContextDisposition | 149 | RequestContextDisposition RequestContextDisposition | ||
| 82 | RequiresNativeOpener bool | 150 | RequiresNativeOpener bool | ||
| 151 | + | ReasonCode HostDecisionReasonCode | |||
| 83 | Reason string | 152 | Reason string | ||
| 84 | } | 153 | } | ||
M internal/application/port/mocks/webview.go +72 -0
| @@ -7,6 +7,7 @@ package mocks | |||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | mock "github.com/stretchr/testify/mock" | 12 | mock "github.com/stretchr/testify/mock" | ||
| 12 | ) | 13 | ) | ||
| @@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop | |||||
| 2631 | return _c | 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 | // 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. | 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 | // The first argument is typically a *testing.T value. | 2707 | // The first argument is typically a *testing.T value. | ||
| 2636 | func NewMockPopupOpenerCapable(t interface { | 2708 | func NewMockPopupOpenerCapable(t interface { | ||
M internal/application/port/webview.go +8 -1
| @@ -77,8 +77,9 @@ type PopupRequest struct { | |||||
| 77 | SourceFrameID string | 77 | SourceFrameID string | ||
| 78 | SourceFrameURL string | 78 | SourceFrameURL string | ||
| 79 | 79 | ||||
| 80 | + | Engine dto.BrowserEngineKind | |||
| 80 | TargetDisposition dto.WindowDisposition | 81 | TargetDisposition dto.WindowDisposition | ||
| 81 | - | WindowFeatures string | 82 | + | PopupFeatures dto.PopupFeatures |
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 84 | // Texture represents a graphics texture returned by the engine. | 85 | // Texture represents a graphics texture returned by the engine. | ||
| @@ -342,6 +343,12 @@ type PopupLifecycleCapable interface { | |||||
| 342 | PrimePopupNavigation(uri string) | 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 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | 352 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | ||
| 346 | // semantics when a popup must be created directly instead of through a native | 353 | // semantics when a popup must be created directly instead of through a native | ||
| 347 | // related-popup lifecycle, and that can report or observe synthetic opener | 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 | binary := filepath.Join(temp, "dumber") | 118 | binary := filepath.Join(temp, "dumber") | ||
| 119 | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | 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 | output := filepath.Join(temp, "artifacts") | 124 | output := filepath.Join(temp, "artifacts") | ||
| 122 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | 125 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | ||
| 123 | cmd.Dir = repoRoot | 126 | cmd.Dir = repoRoot | ||
| @@ -129,6 +132,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||||
| 129 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | 132 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | ||
| 130 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | 133 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | ||
| 131 | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | 134 | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | ||
| 135 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |||
| 132 | ) | 136 | ) | ||
| 133 | result, err := cmd.CombinedOutput() | 137 | result, err := cmd.CombinedOutput() | ||
| 134 | require.NoErrorf(t, err, "collector failed: %s", result) | 138 | require.NoErrorf(t, err, "collector failed: %s", result) | ||
| @@ -144,9 +148,9 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||||
| 144 | } | 148 | } | ||
| 145 | for _, required := range []string{ | 149 | for _, required := range []string{ | ||
| 146 | `"measured_source_revision"`, | 150 | `"measured_source_revision"`, | ||
| 147 | - | `"version": "v0.8.5"`, | 151 | + | `"version": "` + upstreamVersion + `"`, |
| 148 | `"tag": "v0.8.5"`, | 152 | `"tag": "v0.8.5"`, | ||
| 149 | - | `"revision": "b9fb46ef5c04ca0993943c5ca4b6f0fe83583304"`, | 153 | + | `"revision": "` + upstreamRevision + `"`, |
| 150 | } { | 154 | } { | ||
| 151 | require.Contains(t, artifacts.String(), required) | 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 | // Programmatic popup lifecycle callbacks used for OAuth auto-close and | 172 | // Programmatic popup lifecycle callbacks used for OAuth auto-close and | ||
| 173 | // synthetic window.open() proxy support on CEF. | 173 | // synthetic window.open() proxy support on CEF. | ||
| 174 | closeCallbacks []func() | 174 | closeCallbacks []func() | ||
| 175 | + | popupLifecycleClose func() | |||
| 175 | navigationCallbacks []func(string) | 176 | navigationCallbacks []func(string) | ||
| 176 | openerMessageCallbacks []func() | 177 | openerMessageCallbacks []func() | ||
| 177 | openerNavigationCallbacks []func(string) | 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 | func (wv *WebView) SetOnClose(fn func()) { | 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 | // Show implements port.PopupLifecycleCapable. | 668 | // Show implements port.PopupLifecycleCapable. | ||
| @@ -823,15 +830,23 @@ func (wv *WebView) SetNativePopupHostAbort(fn func()) { | |||||
| 823 | } | 830 | } | ||
| 824 | 831 | ||||
| 825 | func (wv *WebView) AbortNativePopupHost() { | 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 | if wv == nil { | 839 | if wv == nil { | ||
| 827 | - | return | 840 | + | return false |
| 828 | } | 841 | } | ||
| 829 | wv.mu.RLock() | 842 | wv.mu.RLock() | ||
| 830 | fn := wv.nativePopupHostAbort | 843 | fn := wv.nativePopupHostAbort | ||
| 831 | wv.mu.RUnlock() | 844 | wv.mu.RUnlock() | ||
| 832 | - | if fn != nil { | 845 | + | if fn == nil { |
| 833 | - | fn() | 846 | + | return false |
| 834 | } | 847 | } | ||
| 848 | + | fn() | |||
| 849 | + | return true | |||
| 835 | } | 850 | } | ||
| 836 | 851 | ||||
| 837 | func (wv *WebView) awaitsNativePopupAttachment() bool { | 852 | func (wv *WebView) awaitsNativePopupAttachment() bool { | ||
| @@ -2291,6 +2306,10 @@ func (wv *WebView) runCloseCallbacks() { | |||||
| 2291 | wv.mu.Lock() | 2306 | wv.mu.Lock() | ||
| 2292 | callbacks := append([]func(){}, wv.closeCallbacks...) | 2307 | callbacks := append([]func(){}, wv.closeCallbacks...) | ||
| 2293 | wv.closeCallbacks = nil | 2308 | wv.closeCallbacks = nil | ||
| 2309 | + | if wv.popupLifecycleClose != nil { | |||
| 2310 | + | callbacks = append(callbacks, wv.popupLifecycleClose) | |||
| 2311 | + | wv.popupLifecycleClose = nil | |||
| 2312 | + | } | |||
| 2294 | wv.mu.Unlock() | 2313 | wv.mu.Unlock() | ||
| 2295 | if len(callbacks) == 0 { | 2314 | if len(callbacks) == 0 { | ||
| 2296 | return | 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 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | 614 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | ||
| 591 | switch disposition { | 615 | switch disposition { | ||
| 592 | case purecef.WindowOpenDispositionWodCurrentTab: | 616 | case purecef.WindowOpenDispositionWodCurrentTab: | ||
| @@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind | |||||
| 616 | // pane handle the navigation. | 640 | // pane handle the navigation. | ||
| 617 | func (h *handlerSet) OnBeforePopup( | 641 | func (h *handlerSet) OnBeforePopup( | ||
| 618 | browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string, | 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 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | 644 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | ||
| 621 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | 645 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | ||
| 622 | ) bool { | 646 | ) bool { | ||
| @@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 649 | 673 | ||||
| 650 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | 674 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | ||
| 651 | popup = cb.OnCreate(port.PopupRequest{ | 675 | popup = cb.OnCreate(port.PopupRequest{ | ||
| 676 | + | Engine: dto.BrowserEngineCEF, | |||
| 677 | + | PopupFeatures: mapCEFPopupFeatures(popupFeatures), | |||
| 652 | TargetURI: targetURL, | 678 | TargetURI: targetURL, | ||
| 653 | FrameName: targetFrameName, | 679 | FrameName: targetFrameName, | ||
| 654 | IsUserGesture: userGesture != 0, | 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 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | 735 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | ||
| 686 | switch decision.Kind { | 736 | switch decision.Kind { | ||
| 687 | - | case dto.HostDecisionCreateNativeWin: | 737 | + | case dto.HostDecisionCreateNativePopup: |
| 688 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | 738 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | ||
| 689 | logging.FromContext(h.currentContext()).Debug(). | 739 | logging.FromContext(h.currentContext()).Debug(). | ||
| 690 | Int32("popup_id", popupID). | 740 | Int32("popup_id", popupID). | ||
| @@ -692,32 +742,65 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 692 | Msg("cef: native popup armed") | 742 | Msg("cef: native popup armed") | ||
| 693 | return false | 743 | return false | ||
| 694 | } | 744 | } | ||
| 695 | - | if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok { | 745 | + | |
| 696 | - | aborter.AbortNativePopupHost() | 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 | logging.FromContext(h.currentContext()).Warn(). | 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 | Int32("popup_id", popupID). | 763 | Int32("popup_id", popupID). | ||
| 701 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 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 | return true | 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 | logging.FromContext(h.currentContext()).Debug(). | 781 | logging.FromContext(h.currentContext()).Debug(). | ||
| 706 | Int32("popup_id", popupID). | 782 | Int32("popup_id", popupID). | ||
| 707 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 783 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | ||
| 708 | Str("decision", string(decision.Kind)). | 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 | return true | 795 | return true | ||
| 711 | default: | 796 | default: | ||
| 712 | - | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | 797 | + | if !cefPopup.preparePopupShellDirectBrowserCreation() { |
| 713 | - | return false | 798 | + | cefPopup.discardNativePopupCandidate() |
| 714 | } | 799 | } | ||
| 715 | - | ||||
| 716 | - | cefPopup.discardNativePopupCandidate() | |||
| 717 | logging.FromContext(h.currentContext()).Debug(). | 800 | logging.FromContext(h.currentContext()).Debug(). | ||
| 718 | Int32("popup_id", popupID). | 801 | Int32("popup_id", popupID). | ||
| 719 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 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 | return true | 804 | return true | ||
| 722 | } | 805 | } | ||
| 723 | } | 806 | } | ||
M internal/infrastructure/cef/webview_handlers_test.go +2 -115
| @@ -3,7 +3,6 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | - | "sync/atomic" | |||
| 7 | "testing" | 6 | "testing" | ||
| 8 | "time" | 7 | "time" | ||
| 9 | 8 | ||||
| @@ -229,6 +228,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe | |||||
| 229 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | 228 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | ||
| 230 | require.True(t, ok) | 229 | require.True(t, ok) | ||
| 231 | require.NotNil(t, rawClient) | 230 | require.NotNil(t, rawClient) | ||
| 231 | + | require.Equal(t, int32(55), wv.nativePopupID) | |||
| 232 | + | require.Same(t, parent, wv.nativePopupParent) | |||
| 232 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | 233 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | ||
| 233 | 234 | ||||
| 234 | wv.updateURI("about:blank") | 235 | wv.updateURI("about:blank") | ||
| @@ -262,120 +263,6 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||||
| 262 | require.True(t, wv.isLoading) | 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 | func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) { | 266 | func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) { | ||
| 380 | windowInfo := purecef.NewWindowInfo() | 267 | windowInfo := purecef.NewWindowInfo() | ||
| 381 | settings := purecef.NewBrowserSettings() | 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 | var _ port.DevToolsOpener = (*WebView)(nil) | 32 | var _ port.DevToolsOpener = (*WebView)(nil) | ||
| 33 | var _ port.Printer = (*WebView)(nil) | 33 | var _ port.Printer = (*WebView)(nil) | ||
| 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | ||
| 35 | + | var _ port.PopupFeatureResolver = (*WebView)(nil) | |||
| 35 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | 36 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | ||
| 36 | 37 | ||||
| 37 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | 38 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | ||
| @@ -50,10 +51,11 @@ const ( | |||||
| 50 | 51 | ||||
| 51 | // PopupRequest contains information about a popup window request from the create signal. | 52 | // PopupRequest contains information about a popup window request from the create signal. | ||
| 52 | type PopupRequest struct { | 53 | type PopupRequest struct { | ||
| 53 | - | TargetURI string | 54 | + | TargetURI string |
| 54 | - | FrameName string // e.g., "_blank", custom name, or empty | 55 | + | FrameName string // e.g., "_blank", custom name, or empty |
| 55 | - | IsUserGesture bool | 56 | + | NavigationType webkit.NavigationType |
| 56 | - | ParentID WebViewID | 57 | + | IsUserGesture bool |
| 58 | + | ParentID WebViewID | |||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | // webViewRegistry tracks all active WebViews. | 61 | // webViewRegistry tracks all active WebViews. | ||
| @@ -154,6 +156,7 @@ type WebView struct { | |||||
| 154 | OnProgressChanged func(float64) | 156 | OnProgressChanged func(float64) | ||
| 155 | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | 157 | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | ||
| 156 | OnClose func() | 158 | OnClose func() | ||
| 159 | + | popupLifecycleClose func() | |||
| 157 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | 160 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | ||
| 158 | OnReadyToShow func() // Called when popup is ready to display | 161 | OnReadyToShow func() // Called when popup is ready to display | ||
| 159 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | 162 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | ||
| @@ -514,14 +517,50 @@ func (wv *WebView) connectWebProcessResponsiveSignal() { | |||||
| 514 | 517 | ||||
| 515 | func (wv *WebView) connectCloseSignal() { | 518 | func (wv *WebView) connectCloseSignal() { | ||
| 516 | closeCb := func(_ webkit.WebView) { | 519 | closeCb := func(_ webkit.WebView) { | ||
| 517 | - | if wv.OnClose != nil { | 520 | + | wv.runCloseCallbacks() |
| 518 | - | wv.OnClose() | |||
| 519 | - | } | |||
| 520 | } | 521 | } | ||
| 521 | sigID := wv.inner.ConnectClose(&closeCb) | 522 | sigID := wv.inner.ConnectClose(&closeCb) | ||
| 522 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 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 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | 564 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | ||
| 526 | wv.mu.Lock() | 565 | wv.mu.Lock() | ||
| 527 | wv.browsingContextDecision = decision | 566 | wv.browsingContextDecision = decision | ||
| @@ -568,10 +607,11 @@ func (wv *WebView) connectCreateSignal() { | |||||
| 568 | } | 607 | } | ||
| 569 | 608 | ||||
| 570 | popupReq := PopupRequest{ | 609 | popupReq := PopupRequest{ | ||
| 571 | - | TargetURI: targetURI, | 610 | + | TargetURI: targetURI, |
| 572 | - | FrameName: navAction.GetFrameName(), | 611 | + | FrameName: navAction.GetFrameName(), |
| 573 | - | IsUserGesture: navAction.IsUserGesture(), | 612 | + | NavigationType: navAction.GetNavigationType(), |
| 574 | - | ParentID: wv.id, | 613 | + | IsUserGesture: navAction.IsUserGesture(), |
| 614 | + | ParentID: wv.id, | |||
| 575 | } | 615 | } | ||
| 576 | 616 | ||||
| 577 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | 617 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | ||
| @@ -594,9 +634,7 @@ func (wv *WebView) connectCreateSignal() { | |||||
| 594 | 634 | ||||
| 595 | func (wv *WebView) connectReadyToShowSignal() { | 635 | func (wv *WebView) connectReadyToShowSignal() { | ||
| 596 | readyToShowCb := func(_ webkit.WebView) { | 636 | readyToShowCb := func(_ webkit.WebView) { | ||
| 597 | - | if wv.OnReadyToShow != nil { | 637 | + | wv.fireReadyToShow() |
| 598 | - | wv.OnReadyToShow() | |||
| 599 | - | } | |||
| 600 | } | 638 | } | ||
| 601 | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) | 639 | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) | ||
| 602 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 640 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| @@ -1624,27 +1662,55 @@ func (*WebView) PrimePopupNavigation(string) {} | |||||
| 1624 | // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display. | 1662 | // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display. | ||
| 1625 | // It implements port.PopupLifecycleCapable. | 1663 | // It implements port.PopupLifecycleCapable. | ||
| 1626 | func (wv *WebView) SetOnReadyToShow(fn func()) { | 1664 | func (wv *WebView) SetOnReadyToShow(fn func()) { | ||
| 1665 | + | if wv == nil { | |||
| 1666 | + | return | |||
| 1667 | + | } | |||
| 1668 | + | wv.mu.Lock() | |||
| 1627 | wv.OnReadyToShow = fn | 1669 | wv.OnReadyToShow = fn | ||
| 1670 | + | wv.mu.Unlock() | |||
| 1628 | } | 1671 | } | ||
| 1629 | 1672 | ||||
| 1630 | - | // SetOnClose composes fn with any existing OnClose handler so multiple callers | 1673 | + | // SetOnClose replaces the coordinator-owned popup lifecycle callback without |
| 1631 | - | // can each register a close callback without overwriting one another. | 1674 | + | // disturbing WebView callbacks or additive OAuth close callbacks. |
| 1632 | - | // It implements port.PopupLifecycleCapable. | |||
| 1633 | func (wv *WebView) SetOnClose(fn func()) { | 1675 | func (wv *WebView) SetOnClose(fn func()) { | ||
| 1634 | - | existing := wv.OnClose | 1676 | + | if wv == nil { |
| 1635 | - | if existing == nil { | |||
| 1636 | - | wv.OnClose = fn | |||
| 1637 | return | 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 | return | 1686 | return | ||
| 1641 | } | 1687 | } | ||
| 1642 | - | wv.OnClose = func() { | 1688 | + | wv.mu.RLock() |
| 1643 | - | existing() | 1689 | + | fn := wv.OnReadyToShow |
| 1690 | + | wv.mu.RUnlock() | |||
| 1691 | + | if fn != nil { | |||
| 1644 | fn() | 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 | // State returns the current WebView state as a snapshot. | 1714 | // State returns the current WebView state as a snapshot. | ||
| 1649 | func (wv *WebView) State() port.WebViewState { | 1715 | func (wv *WebView) State() port.WebViewState { | ||
| 1650 | return port.WebViewState{ | 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 | // SetCallbacks registers callback handlers for WebView events. | 1747 | // SetCallbacks registers callback handlers for WebView events. | ||
| 1662 | // Pass nil to clear all callbacks. | 1748 | // Pass nil to clear all callbacks. | ||
| 1663 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | 1749 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | ||
| @@ -1696,13 +1782,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1696 | wv.OnClose = callbacks.OnClose | 1782 | wv.OnClose = callbacks.OnClose | ||
| 1697 | if callbacks.OnCreate != nil { | 1783 | if callbacks.OnCreate != nil { | ||
| 1698 | wv.OnCreate = func(req PopupRequest) *WebView { | 1784 | wv.OnCreate = func(req PopupRequest) *WebView { | ||
| 1699 | - | portReq := port.PopupRequest{ | 1785 | + | result := callbacks.OnCreate(mapPopupRequest(req)) |
| 1700 | - | TargetURI: req.TargetURI, | |||
| 1701 | - | FrameName: req.FrameName, | |||
| 1702 | - | IsUserGesture: req.IsUserGesture, | |||
| 1703 | - | ParentViewID: req.ParentID, | |||
| 1704 | - | } | |||
| 1705 | - | result := callbacks.OnCreate(portReq) | |||
| 1706 | if result == nil { | 1786 | if result == nil { | ||
| 1707 | return nil | 1787 | return nil | ||
| 1708 | } | 1788 | } | ||
| @@ -1793,9 +1873,7 @@ func (wv *WebView) Close() { | |||||
| 1793 | if wv.destroyed.Load() { | 1873 | if wv.destroyed.Load() { | ||
| 1794 | return | 1874 | return | ||
| 1795 | } | 1875 | } | ||
| 1796 | - | if wv.OnClose != nil { | 1876 | + | wv.runCloseCallbacks() |
| 1797 | - | wv.OnClose() | |||
| 1798 | - | } | |||
| 1799 | } | 1877 | } | ||
| 1800 | 1878 | ||||
| 1801 | // AddCloseCallback implements port.OAuthCallbackCapable. | 1879 | // AddCloseCallback implements port.OAuthCallbackCapable. | ||
| @@ -1931,6 +2009,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) { | |||||
| 1931 | wv.browsingContextDecision = dto.HostDecision{} | 2009 | wv.browsingContextDecision = dto.HostDecision{} | ||
| 1932 | wv.hasBrowsingContextDecision = false | 2010 | wv.hasBrowsingContextDecision = false | ||
| 1933 | wv.nativePopupHostAbort = nil | 2011 | wv.nativePopupHostAbort = nil | ||
| 2012 | + | wv.popupLifecycleClose = nil | |||
| 1934 | wv.mu.Unlock() | 2013 | wv.mu.Unlock() | ||
| 1935 | 2014 | ||||
| 1936 | // 4. Unparent from GTK hierarchy (must happen before process termination) | 2015 | // 4. Unparent from GTK hierarchy (must happen before process termination) | ||
| @@ -2001,6 +2080,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||||
| 2001 | wv.browsingContextDecision = dto.HostDecision{} | 2080 | wv.browsingContextDecision = dto.HostDecision{} | ||
| 2002 | wv.hasBrowsingContextDecision = false | 2081 | wv.hasBrowsingContextDecision = false | ||
| 2003 | wv.nativePopupHostAbort = nil | 2082 | wv.nativePopupHostAbort = nil | ||
| 2083 | + | wv.popupLifecycleClose = nil | |||
| 2004 | wv.lastProgressUpdate.Store(0) | 2084 | wv.lastProgressUpdate.Store(0) | ||
| 2005 | wv.mu.Unlock() | 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 | wv := &WebView{} | 11 | wv := &WebView{} | ||
| 12 | abortCalled := false | 12 | abortCalled := false | ||
| 13 | 13 | ||||
| 14 | - | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | 14 | + | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) |
| 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | ||
| 16 | 16 | ||||
| 17 | wv.ResetForPoolReuse() | 17 | wv.ResetForPoolReuse() | ||
M internal/ui/app.go +57 -33
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | "unsafe" | 14 | "unsafe" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 16 | "github.com/bnema/dumber/internal/application/port" | 17 | "github.com/bnema/dumber/internal/application/port" | ||
| 17 | "github.com/bnema/dumber/internal/application/usecase" | 18 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 18 | "github.com/bnema/dumber/internal/domain/entity" | 19 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -614,6 +615,20 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity. | |||||
| 614 | return a.lastFocusedBrowserWindow() | 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 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | 632 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | ||
| 618 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | 633 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | ||
| 619 | if bw == nil { | 634 | if bw == nil { | ||
| @@ -2941,13 +2956,16 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 2941 | for _, tabID := range tabIDs { | 2956 | for _, tabID := range tabIDs { | ||
| 2942 | a.releaseFloatingSessionsForTab(ctx, tabID) | 2957 | a.releaseFloatingSessionsForTab(ctx, tabID) | ||
| 2943 | } | 2958 | } | ||
| 2959 | + | if a.contentCoord != nil { | |||
| 2960 | + | a.contentCoord.ShutdownPopups() | |||
| 2961 | + | } | |||
| 2944 | if len(a.nativePopupWindows) > 0 { | 2962 | if len(a.nativePopupWindows) > 0 { | ||
| 2945 | popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows)) | 2963 | popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows)) | ||
| 2946 | for popupID := range a.nativePopupWindows { | 2964 | for popupID := range a.nativePopupWindows { | ||
| 2947 | popupIDs = append(popupIDs, popupID) | 2965 | popupIDs = append(popupIDs, popupID) | ||
| 2948 | } | 2966 | } | ||
| 2949 | for _, popupID := range popupIDs { | 2967 | for _, popupID := range popupIDs { | ||
| 2950 | - | a.releaseNativePopupWindow(popupID, false, true) | 2968 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) |
| 2951 | } | 2969 | } | ||
| 2952 | } | 2970 | } | ||
| 2953 | if a.engine != nil { | 2971 | if a.engine != nil { | ||
| @@ -3077,9 +3095,7 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||||
| 3077 | } | 3095 | } | ||
| 3078 | }) | 3096 | }) | ||
| 3079 | // Wire popup tab WebView attachment | 3097 | // Wire popup tab WebView attachment | ||
| 3080 | - | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) { | 3098 | + | a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab) |
| 3081 | - | a.attachPopupToTab(ctx, tabID, pane, wv) | |||
| 3082 | - | }) | |||
| 3083 | 3099 | ||||
| 3084 | for _, bw := range a.browserWindows { | 3100 | for _, bw := range a.browserWindows { | ||
| 3085 | a.wireBrowserWindowTabBar(ctx, bw) | 3101 | a.wireBrowserWindowTabBar(ctx, bw) | ||
| @@ -3157,13 +3173,15 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3157 | &runtimeCfg.Workspace.BrowsingContexts, | 3173 | &runtimeCfg.Workspace.BrowsingContexts, | ||
| 3158 | a.generateID, | 3174 | a.generateID, | ||
| 3159 | ) | 3175 | ) | ||
| 3160 | - | a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) { | 3176 | + | a.contentCoord.SetPopupSourceHostResolver(func(paneID entity.PaneID) dto.SourceHostKind { |
| 3161 | - | bw := a.browserWindowForAnyPane(paneID) | 3177 | + | if a.floatingSessionByPaneID(paneID) != nil { |
| 3162 | - | if bw == nil { | 3178 | + | return dto.SourceHostFloating |
| 3163 | - | return "", false | |||
| 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 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | 3185 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | ||
| 3168 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | 3186 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | ||
| 3169 | a.activateBrowserWindow(bw) | 3187 | a.activateBrowserWindow(bw) | ||
| @@ -3176,7 +3194,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3176 | } | 3194 | } | ||
| 3177 | return a.wsCoord.ClosePaneByID(ctx, paneID) | 3195 | return a.wsCoord.ClosePaneByID(ctx, paneID) | ||
| 3178 | }) | 3196 | }) | ||
| 3179 | - | a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow) | 3197 | + | a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow) |
| 3180 | // Wire tabbed popup behavior to create new tabs in the originating window. | 3198 | // Wire tabbed popup behavior to create new tabs in the originating window. | ||
| 3181 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | 3199 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | ||
| 3182 | 3200 | ||||
| @@ -3930,55 +3948,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||||
| 3930 | 3948 | ||||
| 3931 | // attachPopupToTab attaches a popup WebView to a newly created tab. | 3949 | // attachPopupToTab attaches a popup WebView to a newly created tab. | ||
| 3932 | // This is called when a popup uses tabbed behavior. | 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 | log := logging.FromContext(ctx) | 3952 | log := logging.FromContext(ctx) | ||
| 3935 | - | cleanupUnattachedPopup := func() { | |||
| 3936 | - | if wv != nil { | |||
| 3937 | - | wv.Destroy() | |||
| 3938 | - | } | |||
| 3939 | - | } | |||
| 3940 | - | ||||
| 3941 | wsView := a.workspaceViews[tabID] | 3953 | wsView := a.workspaceViews[tabID] | ||
| 3942 | if wsView == nil { | 3954 | if wsView == nil { | ||
| 3943 | - | cleanupUnattachedPopup() | |||
| 3944 | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | 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 | if pane == nil { | 3958 | if pane == nil { | ||
| 3948 | - | cleanupUnattachedPopup() | |||
| 3949 | log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil") | 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 | if wsView.GetPaneView(pane.ID) == nil { | 3962 | if wsView.GetPaneView(pane.ID) == nil { | ||
| 3953 | - | cleanupUnattachedPopup() | |||
| 3954 | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | 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 | if a.contentCoord == nil { | 3966 | if a.contentCoord == nil { | ||
| 3958 | - | cleanupUnattachedPopup() | |||
| 3959 | log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup") | 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. | 3971 | + | // Wrap and attach before registration so a failed attachment leaves the |
| 3964 | - | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | 3972 | + | // caller-owned WebView untouched and unregistered. |
| 3965 | - | ||||
| 3966 | - | // Wrap and attach widget. | |||
| 3967 | widget := a.contentCoord.WrapWidget(ctx, wv) | 3973 | widget := a.contentCoord.WrapWidget(ctx, wv) | ||
| 3968 | if widget == nil { | 3974 | if widget == nil { | ||
| 3969 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |||
| 3970 | log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview") | 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 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | 3978 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | ||
| 3974 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |||
| 3975 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | 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 | log.Debug(). | 3983 | log.Debug(). | ||
| 3979 | Str("tab_id", string(tabID)). | 3984 | Str("tab_id", string(tabID)). | ||
| 3980 | Str("pane_id", string(pane.ID)). | 3985 | Str("pane_id", string(pane.ID)). | ||
| 3981 | Msg("popup webview attached to tab") | 3986 | Msg("popup webview attached to tab") | ||
| 3987 | + | return nil | |||
| 3982 | } | 3988 | } | ||
| 3983 | 3989 | ||||
| 3984 | // switchWorkspaceView swaps the displayed workspace view for a tab. | 3990 | // switchWorkspaceView swaps the displayed workspace view for a tab. | ||
| @@ -4584,6 +4590,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe | |||||
| 4584 | return nil | 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 | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | 4611 | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | ||
| 4588 | if session == nil || session.overlay == nil || session.resizeWatcherActive { | 4612 | if session == nil || session.overlay == nil || session.resizeWatcherActive { | ||
| 4589 | return | 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 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | 1334 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | ||
| 1317 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | 1335 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | ||
| 1318 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | 1336 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | ||
| @@ -2279,6 +2297,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) { | |||||
| 2279 | mainWindow: first.mainWindow, | 2297 | mainWindow: first.mainWindow, | ||
| 2280 | } | 2298 | } | ||
| 2281 | app.initTabCoordinator(ctx) | 2299 | app.initTabCoordinator(ctx) | ||
| 2300 | + | app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |||
| 2282 | 2301 | ||||
| 2283 | err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{ | 2302 | err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{ | ||
| 2284 | ParentPaneID: secondTab.Workspace.ActivePaneID, | 2303 | ParentPaneID: secondTab.Workspace.ActivePaneID, | ||
| @@ -2538,28 +2557,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f | |||||
| 2538 | func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 } | 2557 | func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 } | ||
| 2539 | func (f *recordingWebView) Destroy() { f.destroyCalls++ } | 2558 | func (f *recordingWebView) Destroy() { f.destroyCalls++ } | ||
| 2540 | 2559 | ||||
| 2541 | - | func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) { | 2560 | + | func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) { |
| 2542 | ctx := context.Background() | 2561 | ctx := context.Background() | ||
| 2543 | popupWV := &recordingWebView{id: 1} | 2562 | popupWV := &recordingWebView{id: 1} | ||
| 2544 | app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}} | 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 { | 2567 | + | if err == nil || popupWV.destroyCalls != 0 { |
| 2549 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | 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 | ctx := context.Background() | 2573 | ctx := context.Background() | ||
| 2555 | popupWV := &recordingWebView{id: 1} | 2574 | popupWV := &recordingWebView{id: 1} | ||
| 2556 | tabID := entity.TabID("tab-1") | 2575 | tabID := entity.TabID("tab-1") | ||
| 2557 | app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}} | 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 { | 2580 | + | if err == nil || popupWV.destroyCalls != 0 { |
| 2562 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | 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 | popupWV := &recordingWebView{id: 1} | 2597 | popupWV := &recordingWebView{id: 1} | ||
| 2579 | - | app.attachPopupToTab(ctx, tabID, pane, popupWV) | 2598 | + | err := app.attachPopupToTab(ctx, tabID, pane, popupWV) |
| 2580 | 2599 | ||||
| 2581 | if got := contentCoord.GetWebView(pane.ID); got != nil { | 2600 | if got := contentCoord.GetWebView(pane.ID); got != nil { | ||
| 2582 | t.Fatalf("popup webview was registered for missing pane view: %v", got) | 2601 | t.Fatalf("popup webview was registered for missing pane view: %v", got) | ||
| 2583 | } | 2602 | } | ||
| 2584 | - | if popupWV.destroyCalls != 1 { | 2603 | + | if err == nil || popupWV.destroyCalls != 0 { |
| 2585 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | 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 | popupWV := &recordingWebView{id: 1} | 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 | if got := contentCoord.GetWebView(pane.ID); got != nil { | 2641 | if got := contentCoord.GetWebView(pane.ID); got != nil { | ||
| 2623 | t.Fatalf("popup webview registration remained after wrap failure: %v", got) | 2642 | t.Fatalf("popup webview registration remained after wrap failure: %v", got) | ||
| 2624 | } | 2643 | } | ||
| 2625 | - | if popupWV.destroyCalls != 1 { | 2644 | + | if err == nil || popupWV.destroyCalls != 0 { |
| 2626 | - | t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls) | 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 | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | 366 | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | ||
| 367 | 367 | ||||
| 368 | a.removeBrowserWindowOrder(id) | 368 | a.removeBrowserWindowOrder(id) | ||
| 369 | + | a.releaseNativePopupsForBrowserWindow(id) | |||
| 369 | a.releaseBrowserWindowTabs(context.Background(), id, removed) | 370 | a.releaseBrowserWindowTabs(context.Background(), id, removed) | ||
| 370 | if removed != nil { | 371 | if removed != nil { | ||
| 371 | removed.teardownForDestroy() | 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 | func (a *App) removeBrowserWindowOrder(id string) { | 394 | func (a *App) removeBrowserWindowOrder(id string) { | ||
| 386 | for i, wid := range a.browserWindowOrder { | 395 | for i, wid := range a.browserWindowOrder { | ||
| 387 | if wid == id { | 396 | if wid == id { | ||
M internal/ui/browser_window_test.go +26 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "unsafe" | 8 | "unsafe" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 12 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 13 | "github.com/bnema/dumber/internal/infrastructure/config" | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| @@ -274,6 +275,31 @@ func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *t | |||||
| 274 | assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID)) | 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 | func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | 303 | func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | ||
| 278 | ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned"))) | 304 | ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned"))) | ||
| 279 | otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other"))) | 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 | "github.com/bnema/dumber/internal/application/dto" | 6 | "github.com/bnema/dumber/internal/application/dto" | ||
| 7 | "github.com/bnema/dumber/internal/application/port" | 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{} | 12 | + | func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) { |
| 11 | - | 13 | + | logger.Debug(). | |
| 12 | - | func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision { | 14 | + | Str("engine", string(req.Engine)). |
| 13 | - | decision := dto.HostDecision{ | 15 | + | Str("source_host", string(req.SourceHost)). |
| 14 | - | RequestContextDisposition: req.RequestContextDisposition, | 16 | + | Str("decision", string(decision.Kind)). |
| 15 | - | RequiresNativeOpener: req.RequiresNativeOpener, | 17 | + | Str("target_disposition", string(req.TargetDisposition)). |
| 16 | - | } | 18 | + | Str("reason_code", string(decision.ReasonCode)). |
| 17 | - | if decision.RequestContextDisposition == "" { | 19 | + | Msg("browsing context host decided") |
| 18 | - | decision.RequestContextDisposition = dto.RequestContextInheritParent | 20 | + | } |
| 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 | - | } | |||
| 51 | 21 | ||||
| 52 | - | decision.Kind = dto.HostDecisionCreatePane | 22 | + | func logBrowsingContextFailure( |
| 53 | - | decision.BrowsingContextName = name | 23 | + | logger zerolog.Logger, |
| 54 | - | decision.Reason = "pane-hosted browsing context" | 24 | + | req dto.NewBrowsingContextRequest, |
| 55 | - | return decision | 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 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | 38 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | ||
| 59 | return dto.NewBrowsingContextRequest{ | 39 | return dto.NewBrowsingContextRequest{ | ||
| 60 | ParentWebViewID: uint64(req.ParentViewID), | 40 | ParentWebViewID: uint64(req.ParentViewID), | ||
| 41 | + | Engine: req.Engine, | |||
| 61 | SourceBrowserID: req.SourceBrowserID, | 42 | SourceBrowserID: req.SourceBrowserID, | ||
| 62 | SourceFrameID: req.SourceFrameID, | 43 | SourceFrameID: req.SourceFrameID, | ||
| 63 | SourceFrameURL: req.SourceFrameURL, | 44 | SourceFrameURL: req.SourceFrameURL, | ||
| @@ -66,7 +47,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont | |||||
| 66 | TargetDisposition: inferPopupWindowDisposition(req), | 47 | TargetDisposition: inferPopupWindowDisposition(req), | ||
| 67 | IsUserGesture: req.IsUserGesture, | 48 | IsUserGesture: req.IsUserGesture, | ||
| 68 | NoJavaScriptAccess: req.NoJavaScriptAccess, | 49 | NoJavaScriptAccess: req.NoJavaScriptAccess, | ||
| 69 | - | WindowFeatures: req.WindowFeatures, | 50 | + | PopupFeatures: req.PopupFeatures, |
| 70 | TriggerKind: inferPopupTriggerKind(req), | 51 | TriggerKind: inferPopupTriggerKind(req), | ||
| 71 | AuthIntent: IsOAuthURL(req.TargetURI), | 52 | AuthIntent: IsOAuthURL(req.TargetURI), | ||
| 72 | RequiresNativeOpener: false, | 53 | RequiresNativeOpener: false, | ||
| @@ -99,38 +80,15 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition { | |||||
| 99 | } | 80 | } | ||
| 100 | 81 | ||||
| 101 | func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind { | 82 | func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind { | ||
| 83 | + | if usecase.ReusableBrowsingContextName(req.FrameName) != "" { | |||
| 84 | + | return dto.TriggerNamedTargetNavigation | |||
| 85 | + | } | |||
| 102 | switch inferPopupWindowDisposition(req) { | 86 | switch inferPopupWindowDisposition(req) { | ||
| 103 | - | case dto.WindowDispositionCurrentTab: | 87 | + | case dto.WindowDispositionCurrentTab, dto.WindowDispositionNewTab: |
| 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 | - | } | |||
| 112 | return dto.TriggerLinkNewPage | 88 | return dto.TriggerLinkNewPage | ||
| 113 | case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow: | 89 | case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow: | ||
| 114 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |||
| 115 | - | return dto.TriggerNamedTargetNavigation | |||
| 116 | - | } | |||
| 117 | return dto.TriggerScriptWindowOpen | 90 | return dto.TriggerScriptWindowOpen | ||
| 118 | default: | 91 | default: | ||
| 119 | - | if reusableBrowsingContextName(req.FrameName) != "" { | |||
| 120 | - | return dto.TriggerNamedTargetNavigation | |||
| 121 | - | } | |||
| 122 | return dto.TriggerUnknown | 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 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func TestBrowsingContextPolicyDecide(t *testing.T) { | 11 | + | func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) { |
| 12 | t.Parallel() | 12 | t.Parallel() | ||
| 13 | 13 | ||||
| 14 | - | policy := browsingContextPolicy{} | 14 | + | features := dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 320, WidthSet: true} |
| 15 | - | tests := []struct { | 15 | + | got := buildPopupBrowsingContextRequest(port.PopupRequest{Engine: dto.BrowserEngineCEF, PopupFeatures: features, TargetURI: "https://example.com", ParentViewID: 42}) |
| 16 | - | name string | 16 | + | assert.Equal(t, dto.BrowserEngineCEF, got.Engine) |
| 17 | - | request dto.NewBrowsingContextRequest | 17 | + | assert.Equal(t, features, got.PopupFeatures) |
| 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)) | |||
| 111 | } | 18 | } | ||
| 112 | 19 | ||||
| 113 | - | func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) { | 20 | + | func TestInferPopupTriggerKind(t *testing.T) { |
| 114 | t.Parallel() | 21 | t.Parallel() | ||
| 115 | - | 22 | + | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(port.PopupRequest{TargetURI: "https://example.com/window-open", TargetDisposition: dto.WindowDispositionNewPopup})) | |
| 116 | - | tests := []struct { | 23 | + | assert.Equal(t, dto.TriggerLinkNewPage, inferPopupTriggerKind(port.PopupRequest{TargetDisposition: dto.WindowDispositionCurrentTab})) |
| 117 | - | name string | 24 | + | assert.Equal(t, dto.TriggerNamedTargetNavigation, inferPopupTriggerKind(port.PopupRequest{FrameName: "shared", TargetDisposition: dto.WindowDispositionCurrentTab})) |
| 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 | - | } | |||
| 146 | } | 25 | } | ||
| 147 | 26 | ||||
| 148 | - | func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) { | 27 | + | func TestInferPopupWindowDisposition(t *testing.T) { |
| 149 | t.Parallel() | 28 | t.Parallel() | ||
| 150 | - | 29 | + | assert.Equal(t, dto.WindowDispositionNewTab, inferPopupWindowDisposition(port.PopupRequest{FrameName: " _BlAnK "})) | |
| 151 | - | tests := []struct { | 30 | + | assert.Equal(t, dto.WindowDispositionNewPopup, inferPopupWindowDisposition(port.PopupRequest{FrameName: "shared-pane"})) |
| 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 | - | } | |||
| 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 | // ReleaseWebView returns the WebView for a pane to the pool. | 48 | // ReleaseWebView returns the WebView for a pane to the pool. | ||
| 49 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | 49 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | ||
| 50 | log := logging.FromContext(ctx) | 50 | log := logging.FromContext(ctx) | ||
| 51 | + | c.ensurePopupManager().cancelDeferredPopupsForParent(paneID) | |||
| 51 | 52 | ||||
| 52 | // deleteWebViewLocked clears reveal state before returning, even when a | 53 | // deleteWebViewLocked clears reveal state before returning, even when a | ||
| 53 | // previous cleanup already removed this pane's mapping. | 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 | return "", false | 223 | return "", false | ||
| 224 | }) | 224 | }) | ||
| 225 | c.webViews[paneID] = wv | 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 | _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane) | 228 | _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane) | ||
| 229 | require.True(t, okBefore) | 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 | type namedBrowsingContextKey struct { | 10 | type namedBrowsingContextKey struct { | ||
| 11 | - | WindowID string | 11 | + | OwnerWindowID string |
| 12 | - | Name string | 12 | + | Name string |
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | type namedBrowsingContextState struct { | 15 | type namedBrowsingContextState struct { | ||
| 16 | - | PaneID entity.PaneID | 16 | + | PaneID entity.PaneID |
| 17 | - | WebViewID port.WebViewID | 17 | + | WebViewID port.WebViewID |
| 18 | + | HostWindowID string | |||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | type namedBrowsingContextRegistry struct { | 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 | + | func (r *namedBrowsingContextRegistry) Register(ownerWindowID, hostWindowID, name string, paneID entity.PaneID, webViewID port.WebViewID) { |
| 32 | - | if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 { | 33 | + | if r == nil || ownerWindowID == "" || hostWindowID == "" || name == "" || paneID == "" || webViewID == 0 { |
| 33 | return | 34 | return | ||
| 34 | } | 35 | } | ||
| 35 | r.mu.Lock() | 36 | r.mu.Lock() | ||
| 36 | - | r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{ | 37 | + | r.contexts[namedBrowsingContextKey{OwnerWindowID: ownerWindowID, Name: name}] = namedBrowsingContextState{ |
| 37 | - | PaneID: paneID, | 38 | + | PaneID: paneID, WebViewID: webViewID, HostWindowID: hostWindowID, |
| 38 | - | WebViewID: webViewID, | |||
| 39 | } | 39 | } | ||
| 40 | r.mu.Unlock() | 40 | r.mu.Unlock() | ||
| 41 | } | 41 | } | ||
| @@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||||
| 49 | return namedBrowsingContextState{}, nil, false | 49 | return namedBrowsingContextState{}, nil, false | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | key := namedBrowsingContextKey{WindowID: windowID, Name: name} | 52 | + | key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name} |
| 53 | r.mu.RLock() | 53 | r.mu.RLock() | ||
| 54 | state, ok := r.contexts[key] | 54 | state, ok := r.contexts[key] | ||
| 55 | r.mu.RUnlock() | 55 | r.mu.RUnlock() | ||
| @@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | currentWindowID, ok := resolveWindowID(state.PaneID) | 60 | currentWindowID, ok := resolveWindowID(state.PaneID) | ||
| 61 | - | if !ok || currentWindowID != windowID { | 61 | + | if !ok || currentWindowID != state.HostWindowID { |
| 62 | r.deleteIfStateMatches(key, state) | 62 | r.deleteIfStateMatches(key, state) | ||
| 63 | return namedBrowsingContextState{}, nil, false | 63 | return namedBrowsingContextState{}, nil, false | ||
| 64 | } | 64 | } | ||
| @@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) { | |||||
| 114 | return | 114 | return | ||
| 115 | } | 115 | } | ||
| 116 | r.mu.Lock() | 116 | r.mu.Lock() | ||
| 117 | - | for key := range r.contexts { | 117 | + | for key, state := range r.contexts { |
| 118 | - | if key.WindowID == windowID { | 118 | + | if key.OwnerWindowID == windowID || state.HostWindowID == windowID { |
| 119 | delete(r.contexts, key) | 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 | reg := newNamedBrowsingContextRegistry() | 15 | reg := newNamedBrowsingContextRegistry() | ||
| 16 | paneID := entity.PaneID("pane-1") | 16 | paneID := entity.PaneID("pane-1") | ||
| 17 | webViewID := port.WebViewID(41) | 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 | wv := mocks.NewMockWebView(t) | 20 | wv := mocks.NewMockWebView(t) | ||
| 21 | wv.EXPECT().IsDestroyed().Return(false).Once() | 21 | wv.EXPECT().IsDestroyed().Return(false).Once() | ||
| @@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T) | |||||
| 39 | assert.Same(t, wv, gotWV) | 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 | func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) { | 72 | func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) { | ||
| 43 | t.Parallel() | 73 | t.Parallel() | ||
| 44 | 74 | ||||
| 45 | reg := newNamedBrowsingContextRegistry() | 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 | _, _, ok := reg.Lookup( | 78 | _, _, ok := reg.Lookup( | ||
| 49 | "window-2", | 79 | "window-2", | ||
| @@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi | |||||
| 60 | 90 | ||||
| 61 | reg := newNamedBrowsingContextRegistry() | 91 | reg := newNamedBrowsingContextRegistry() | ||
| 62 | paneID := entity.PaneID("pane-1") | 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 | _, _, ok := reg.Lookup( | 95 | _, _, ok := reg.Lookup( | ||
| 66 | "window-1", | 96 | "window-1", | ||
| @@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||||
| 86 | oldPaneID := entity.PaneID("pane-old") | 116 | oldPaneID := entity.PaneID("pane-old") | ||
| 87 | newPaneID := entity.PaneID("pane-new") | 117 | newPaneID := entity.PaneID("pane-new") | ||
| 88 | newWebViewID := port.WebViewID(42) | 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 | _, _, ok := reg.Lookup( | 121 | _, _, ok := reg.Lookup( | ||
| 92 | "window-1", | 122 | "window-1", | ||
| @@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||||
| 94 | func(entity.PaneID) port.WebView { return nil }, | 124 | func(entity.PaneID) port.WebView { return nil }, | ||
| 95 | func(gotPaneID entity.PaneID) (string, bool) { | 125 | func(gotPaneID entity.PaneID) (string, bool) { | ||
| 96 | assert.Equal(t, oldPaneID, gotPaneID) | 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 | return "window-2", true | 128 | return "window-2", true | ||
| 99 | }, | 129 | }, | ||
| 100 | ) | 130 | ) | ||
| @@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS | |||||
| 128 | oldPaneID := entity.PaneID("pane-old") | 158 | oldPaneID := entity.PaneID("pane-old") | ||
| 129 | newPaneID := entity.PaneID("pane-new") | 159 | newPaneID := entity.PaneID("pane-new") | ||
| 130 | newWebViewID := port.WebViewID(42) | 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 | _, _, ok := reg.Lookup( | 163 | _, _, ok := reg.Lookup( | ||
| 134 | "window-1", | 164 | "window-1", | ||
| 135 | "shared-pane", | 165 | "shared-pane", | ||
| 136 | func(gotPaneID entity.PaneID) port.WebView { | 166 | func(gotPaneID entity.PaneID) port.WebView { | ||
| 137 | assert.Equal(t, oldPaneID, gotPaneID) | 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 | return nil | 169 | return nil | ||
| 140 | }, | 170 | }, | ||
| 141 | func(gotPaneID entity.PaneID) (string, bool) { | 171 | func(gotPaneID entity.PaneID) (string, bool) { | ||
M internal/ui/coordinator/content/popup.go +59 -8
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -105,14 +106,45 @@ type InsertPopupInput struct { | |||||
| 105 | 106 | ||||
| 106 | // NativePopupInput contains the data needed to host a native-required popup in | 107 | // NativePopupInput contains the data needed to host a native-required popup in | ||
| 107 | // a dedicated top-level GTK window instead of the workspace. | 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 | type NativePopupInput struct { | 136 | type NativePopupInput struct { | ||
| 109 | - | ParentPaneID entity.PaneID | 137 | + | ParentPaneID entity.PaneID |
| 110 | - | ParentWebViewID port.WebViewID | 138 | + | ParentWebViewID port.WebViewID |
| 111 | - | ParentURIAtOpen string | 139 | + | ParentURIAtOpen string |
| 112 | - | PopupWebView port.WebView | 140 | + | PopupWebView port.WebView |
| 113 | - | TargetURI string | 141 | + | TargetURI string |
| 114 | - | Request port.PopupRequest | 142 | + | Request port.PopupRequest |
| 115 | - | ObserveOAuthAutoClose bool | 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 | // GetBehavior returns the appropriate behavior based on popup type and config. | 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 | c.ensurePopupManager().setWindowIDResolver(fn) | 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 | func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) { | 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 | // SetOnInsertPopup sets the callback to insert popups into the workspace. | 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 | "github.com/bnema/dumber/internal/application/dto" | 10 | "github.com/bnema/dumber/internal/application/dto" | ||
| 11 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 12 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | ) | 15 | ) | ||
| @@ -17,20 +18,34 @@ import ( | |||||
| 17 | // adapter layer so popup pane bookkeeping and workspace orchestration do not | 18 | // adapter layer so popup pane bookkeeping and workspace orchestration do not | ||
| 18 | // leak into the application/usecase or domain layers. | 19 | // leak into the application/usecase or domain layers. | ||
| 19 | type popupManager struct { | 20 | type popupManager struct { | ||
| 20 | - | factory port.WebViewFactory | 21 | + | factory port.WebViewFactory |
| 21 | - | popupConfig entity.BrowsingContextConfig | 22 | + | popupConfig entity.BrowsingContextConfig |
| 22 | - | hasPopupConfig bool | 23 | + | hasPopupConfig bool |
| 23 | - | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | 24 | + | onInsertPopup func(ctx context.Context, input InsertPopupInput) error |
| 24 | - | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error | 25 | + | onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error) |
| 25 | - | onClosePane func(ctx context.Context, paneID entity.PaneID) error | 26 | + | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error |
| 26 | - | generatePaneID func() string | 27 | + | onStagePopup func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error) |
| 27 | - | windowIDForPane func(entity.PaneID) (string, bool) | 28 | + | onClosePane func(ctx context.Context, paneID entity.PaneID) error |
| 28 | - | policy browsingContextPolicy | 29 | + | generatePaneID func() string |
| 29 | - | namedContexts *namedBrowsingContextRegistry | 30 | + | windowIDForPane func(entity.PaneID) (string, bool) |
| 30 | - | pendingPopups map[port.WebViewID]*PendingPopup | 31 | + | sourceHostForPane func(entity.PaneID) dto.SourceHostKind |
| 31 | - | popupOAuth map[port.WebViewID]*popupOAuthState | 32 | + | policy usecase.BrowsingContextPolicy |
| 32 | - | popupRefresh map[entity.PaneID]*time.Timer | 33 | + | namedContexts *namedBrowsingContextRegistry |
| 33 | - | mu sync.RWMutex | 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 | type popupOAuthState struct { | 51 | type popupOAuthState struct { | ||
| @@ -83,6 +98,9 @@ func (pm *popupManager) ensureInitialized() { | |||||
| 83 | if pm.pendingPopups == nil { | 98 | if pm.pendingPopups == nil { | ||
| 84 | pm.pendingPopups = make(map[port.WebViewID]*PendingPopup) | 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 | if pm.namedContexts == nil { | 104 | if pm.namedContexts == nil { | ||
| 87 | pm.namedContexts = newNamedBrowsingContextRegistry() | 105 | pm.namedContexts = newNamedBrowsingContextRegistry() | ||
| 88 | } | 106 | } | ||
| @@ -143,6 +161,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool | |||||
| 143 | pm.windowIDForPane = fn | 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 | func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) { | 208 | func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) { | ||
| 147 | behavior := GetBehavior(PopupTypeTab, cfg) | 209 | behavior := GetBehavior(PopupTypeTab, cfg) | ||
| 148 | placement := "right" | 210 | placement := "right" | ||
| @@ -209,7 +271,7 @@ func (pm *popupManager) lookupReusableNamedPopup( | |||||
| 209 | return nil, false | 271 | return nil, false | ||
| 210 | } | 272 | } | ||
| 211 | 273 | ||||
| 212 | - | name := reusableBrowsingContextName(frameName) | 274 | + | name := usecase.ReusableBrowsingContextName(frameName) |
| 213 | if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil { | 275 | if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil { | ||
| 214 | return nil, false | 276 | return nil, false | ||
| 215 | } | 277 | } | ||
| @@ -227,18 +289,32 @@ func (pm *popupManager) lookupReusableNamedPopup( | |||||
| 227 | } | 289 | } | ||
| 228 | 290 | ||||
| 229 | func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) { | 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 | return | 293 | return | ||
| 232 | } | 294 | } | ||
| 233 | - | name := reusableBrowsingContextName(frameName) | 295 | + | hostWindowID, ok := pm.windowIDForPane(paneID) |
| 234 | - | if name == "" || pm.windowIDForPane == nil { | 296 | + | if !ok { |
| 235 | return | 297 | return | ||
| 236 | } | 298 | } | ||
| 237 | - | windowID, ok := pm.windowIDForPane(parentPaneID) | 299 | + | pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID) |
| 238 | - | if !ok || windowID == "" { | 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 | return | 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 | func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) { | 320 | func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) { | ||
| @@ -295,6 +371,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, | |||||
| 295 | return pending, ok | 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 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | 396 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | ||
| 299 | if pm == nil { | 397 | if pm == nil { | ||
| 300 | return | 398 | return | ||
| @@ -451,45 +549,6 @@ func (pm *popupManager) popupParentURIAtOpen( | |||||
| 451 | return parentURIAtOpen | 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 | func (pm *popupManager) handlePopupCreate( | 552 | func (pm *popupManager) handlePopupCreate( | ||
| 494 | ctx context.Context, | 553 | ctx context.Context, | ||
| 495 | hooks popupCoordinatorHooks, | 554 | hooks popupCoordinatorHooks, | ||
| @@ -499,21 +558,8 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 499 | ) port.WebView { | 558 | ) port.WebView { | ||
| 500 | log := logging.FromContext(ctx) | 559 | log := logging.FromContext(ctx) | ||
| 501 | 560 | ||||
| 502 | - | log.Debug(). | 561 | + | cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID) |
| 503 | - | Str("parent_pane", string(parentPaneID)). | 562 | + | if !routingEnabled { |
| 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") | |||
| 517 | return nil | 563 | return nil | ||
| 518 | } | 564 | } | ||
| 519 | 565 | ||||
| @@ -521,16 +567,13 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 521 | parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI) | 567 | parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI) | ||
| 522 | 568 | ||||
| 523 | request := buildPopupBrowsingContextRequest(req) | 569 | request := buildPopupBrowsingContextRequest(req) | ||
| 570 | + | request.SourceHost = sourceHost | |||
| 524 | namedContextExists := false | 571 | namedContextExists := false | ||
| 525 | if !req.NoJavaScriptAccess { | 572 | if !req.NoJavaScriptAccess { | ||
| 526 | _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks) | 573 | _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks) | ||
| 527 | } | 574 | } | ||
| 528 | decision := pm.policy.Decide(request, namedContextExists) | 575 | decision := pm.policy.Decide(request, namedContextExists) | ||
| 529 | - | log.Debug(). | 576 | + | logBrowsingContextDecision(*log, request, decision) |
| 530 | - | Str("decision", string(decision.Kind)). | |||
| 531 | - | Str("reason", decision.Reason). | |||
| 532 | - | Str("context_name", decision.BrowsingContextName). | |||
| 533 | - | Msg("browsing context host decision") | |||
| 534 | 577 | ||||
| 535 | switch decision.Kind { | 578 | switch decision.Kind { | ||
| 536 | case dto.HostDecisionDeny: | 579 | case dto.HostDecisionDeny: | ||
| @@ -543,14 +586,24 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 543 | reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI) | 586 | reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI) | ||
| 544 | if ok { | 587 | if ok { | ||
| 545 | pm.setBrowsingContextDecision(reused, decision) | 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 | log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable") | 594 | log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable") | ||
| 549 | - | decision.Kind = dto.HostDecisionCreatePane | 595 | + | // Lookup removes the stale registry entry. Re-enter policy once so the |
| 550 | - | decision.ReuseContextName = "" | 596 | + | // replacement host remains source-aware instead of forcing a workspace pane. |
| 551 | - | decision.Reason = "named browsing context unavailable; creating replacement pane" | 597 | + | return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req) |
| 552 | - | case dto.HostDecisionCreateNativeWin: | 598 | + | case dto.HostDecisionNavigateSource: |
| 553 | - | return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision) | 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 | case dto.HostDecisionCreatePane: | 607 | case dto.HostDecisionCreatePane: | ||
| 555 | // Continue below. | 608 | // Continue below. | ||
| 556 | break | 609 | break | ||
| @@ -812,6 +865,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin | |||||
| 812 | log.Info().Str("pane_id", string(paneID)).Msg("popup closed") | 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 | func (pm *popupManager) handleLinkMiddleClick( | 894 | func (pm *popupManager) handleLinkMiddleClick( | ||
| 816 | ctx context.Context, | 895 | ctx context.Context, | ||
| 817 | hooks popupCoordinatorHooks, | 896 | hooks popupCoordinatorHooks, | ||
| @@ -825,13 +904,8 @@ func (pm *popupManager) handleLinkMiddleClick( | |||||
| 825 | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | 904 | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | ||
| 826 | Msg("middle-click/ctrl+click on link") | 905 | Msg("middle-click/ctrl+click on link") | ||
| 827 | 906 | ||||
| 828 | - | cfg := pm.currentPopupConfig() | 907 | + | _, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID) |
| 829 | - | if cfg != nil && !cfg.OpenInNewPane { | 908 | + | if !routingEnabled { |
| 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") | |||
| 835 | return false | 909 | return false | ||
| 836 | } | 910 | } | ||
| 837 | if hooks.getWebView == nil { | 911 | if hooks.getWebView == nil { | ||
| @@ -844,16 +918,33 @@ func (pm *popupManager) handleLinkMiddleClick( | |||||
| 844 | return false | 918 | return false | ||
| 845 | } | 919 | } | ||
| 846 | 920 | ||||
| 847 | - | decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false) | 921 | + | request := buildLinkBrowsingContextRequest(parentWV.ID(), uri) |
| 848 | - | log.Debug(). | 922 | + | request.SourceHost = sourceHost |
| 849 | - | Str("decision", string(decision.Kind)). | 923 | + | decision := pm.policy.Decide(request, false) |
| 850 | - | Str("reason", decision.Reason). | 924 | + | logBrowsingContextDecision(*log, request, decision) |
| 851 | - | Msg("middle-click browsing context host 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 | if decision.Kind != dto.HostDecisionCreatePane { | 931 | if decision.Kind != dto.HostDecisionCreatePane { | ||
| 853 | log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted") | 932 | log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted") | ||
| 854 | return false | 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 | newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true) | 948 | newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true) | ||
| 858 | if err != nil { | 949 | if err != nil { | ||
| 859 | log.Error().Err(err).Msg("failed to create webview for middle-click") | 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 | package content | 1 | package content | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "sync" | |||
| 7 | "testing" | 4 | "testing" | ||
| 8 | 5 | ||||
| 9 | "github.com/stretchr/testify/assert" | 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 | "github.com/bnema/dumber/internal/domain/entity" | 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 | assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg)) | 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 | onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab) | 35 | onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab) | ||
| 36 | onQuit func() | 36 | onQuit func() | ||
| 37 | onCurrentWindowEmpty func(ctx context.Context, target TabTarget) | 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 | 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 | 39 | + | onStateChanged func() // For session snapshots |
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | // TabCoordinatorConfig holds configuration for TabCoordinator. | 42 | // TabCoordinatorConfig holds configuration for TabCoordinator. | ||
| @@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) { | |||||
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | 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 | c.onAttachPopupToTab = fn | 136 | c.onAttachPopupToTab = fn | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| @@ -540,9 +540,20 @@ func (c *TabCoordinator) CreateWithPane( | |||||
| 540 | // Update tab bar visibility | 540 | // Update tab bar visibility | ||
| 541 | c.UpdateBarVisibility(ctx, target) | 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 | if c.onAttachPopupToTab != nil { | 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 | // Switch to the new tab's workspace view | 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 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/ui/component" | 9 | "github.com/bnema/dumber/internal/ui/component" | ||
| 10 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |||
| 10 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | 11 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | ||
| 11 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| @@ -312,6 +313,35 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) { | |||||
| 312 | assert.False(t, handled) | 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 | func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) { | 345 | func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) { | ||
| 316 | app, _, session := newFloatingPaneTestApp(t) | 346 | app, _, session := newFloatingPaneTestApp(t) | ||
| 317 | 347 | ||||
M internal/ui/native_popup_window.go +50 -25
| @@ -14,25 +14,32 @@ import ( | |||||
| 14 | "github.com/bnema/puregotk/v4/gtk" | 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 | type nativePopupWindow struct { | 24 | type nativePopupWindow struct { | ||
| 18 | popupID port.WebViewID | 25 | popupID port.WebViewID | ||
| 19 | parentPaneID entity.PaneID | 26 | parentPaneID entity.PaneID | ||
| 20 | parentWindowID string | 27 | parentWindowID string | ||
| 21 | - | popupWindow *window.PopupWindow | 28 | + | popupWindow nativePopupShell |
| 22 | webView port.WebView | 29 | webView port.WebView | ||
| 23 | closeOnce sync.Once | 30 | closeOnce sync.Once | ||
| 24 | } | 31 | } | ||
| 25 | 32 | ||||
| 26 | - | type nativePopupDestroyer interface { | 33 | + | type nativePopupReleaseMode int |
| 27 | - | Destroy() | 34 | + | |
| 28 | - | } | 35 | + | const ( |
| 36 | + | nativePopupReleaseDestroy nativePopupReleaseMode = iota | |||
| 37 | + | nativePopupReleaseDetach | |||
| 38 | + | ) | |||
| 29 | 39 | ||||
| 30 | var newPopupWindow = window.NewPopup | 40 | var newPopupWindow = window.NewPopup | ||
| 31 | 41 | ||||
| 32 | - | func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) { | 42 | + | func destroyFailedNativePopupSetup(popupShell nativePopupShell) { |
| 33 | - | if wv != nil && !wv.IsDestroyed() { | |||
| 34 | - | wv.Destroy() | |||
| 35 | - | } | |||
| 36 | if popupShell != nil { | 43 | if popupShell != nil { | ||
| 37 | popupShell.Destroy() | 44 | popupShell.Destroy() | ||
| 38 | } | 45 | } | ||
| @@ -67,17 +74,16 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||||
| 67 | 74 | ||||
| 68 | popupShell, err := newPopupWindow(ctx, a.gtkApp) | 75 | popupShell, err := newPopupWindow(ctx, a.gtkApp) | ||
| 69 | if err != nil { | 76 | if err != nil { | ||
| 70 | - | destroyFailedNativePopupSetup(nil, input.PopupWebView) | |||
| 71 | return err | 77 | return err | ||
| 72 | } | 78 | } | ||
| 73 | if a.contentCoord == nil { | 79 | if a.contentCoord == nil { | ||
| 74 | - | destroyFailedNativePopupSetup(popupShell, input.PopupWebView) | 80 | + | destroyFailedNativePopupSetup(popupShell) |
| 75 | return fmt.Errorf("content coordinator not available for native popup") | 81 | return fmt.Errorf("content coordinator not available for native popup") | ||
| 76 | } | 82 | } | ||
| 77 | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | 83 | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | ||
| 78 | gtkWidget, err := prepareNativePopupContentWidget(widget) | 84 | gtkWidget, err := prepareNativePopupContentWidget(widget) | ||
| 79 | if err != nil { | 85 | if err != nil { | ||
| 80 | - | destroyFailedNativePopupSetup(popupShell, input.PopupWebView) | 86 | + | destroyFailedNativePopupSetup(popupShell) |
| 81 | return err | 87 | return err | ||
| 82 | } | 88 | } | ||
| 83 | popupShell.SetContent(gtkWidget) | 89 | popupShell.SetContent(gtkWidget) | ||
| @@ -102,7 +108,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||||
| 102 | 108 | ||||
| 103 | if popupShell.Window() != nil { | 109 | if popupShell.Window() != nil { | ||
| 104 | closeRequestCb := func(_ gtk.Window) bool { | 110 | closeRequestCb := func(_ gtk.Window) bool { | ||
| 105 | - | a.releaseNativePopupWindow(popupID, false, false) | 111 | + | a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy) |
| 106 | return false | 112 | return false | ||
| 107 | } | 113 | } | ||
| 108 | popupShell.Window().ConnectCloseRequest(&closeRequestCb) | 114 | popupShell.Window().ConnectCloseRequest(&closeRequestCb) | ||
| @@ -111,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||||
| 111 | if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok { | 117 | if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok { | ||
| 112 | aborter.SetNativePopupHostAbort(func() { | 118 | aborter.SetNativePopupHostAbort(func() { | ||
| 113 | a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() { | 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 | lifecycle.SetOnClose(func() { | 130 | lifecycle.SetOnClose(func() { | ||
| 125 | a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() { | 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 | } else { | 135 | } else { | ||
| @@ -132,7 +138,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop | |||||
| 132 | if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok { | 138 | if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok { | ||
| 133 | oauthWV.AddCloseCallback(func() { | 139 | oauthWV.AddCloseCallback(func() { | ||
| 134 | a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() { | 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 | return nil | 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 | func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) { | 173 | func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) { | ||
| 152 | if a == nil || fn == nil { | 174 | if a == nil || fn == nil { | ||
| 153 | return | 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 | if a == nil || a.nativePopupWindows == nil { | 207 | if a == nil || a.nativePopupWindows == nil { | ||
| 186 | - | return | 208 | + | return nil |
| 187 | } | 209 | } | ||
| 188 | state := a.nativePopupWindows[popupID] | 210 | state := a.nativePopupWindows[popupID] | ||
| 189 | if state == nil { | 211 | if state == nil { | ||
| 190 | - | return | 212 | + | return nil |
| 191 | } | 213 | } | ||
| 214 | + | var detached port.WebView | |||
| 192 | state.closeOnce.Do(func() { | 215 | state.closeOnce.Do(func() { | ||
| 193 | delete(a.nativePopupWindows, popupID) | 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 | state.webView.Destroy() | 224 | state.webView.Destroy() | ||
| 196 | } | 225 | } | ||
| 197 | if state.popupWindow != nil { | 226 | if state.popupWindow != nil { | ||
| 198 | - | switch { | 227 | + | state.popupWindow.Destroy() |
| 199 | - | case destroyWindow: | |||
| 200 | - | state.popupWindow.Destroy() | |||
| 201 | - | case closeWindow: | |||
| 202 | - | state.popupWindow.Close() | |||
| 203 | - | } | |||
| 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 | type popupDestroySpy struct { | 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() { | 27 | + | func (p *popupDestroySpy) Destroy() { p.destroyed++ } |
| 25 | - | p.destroyed = true | 28 | + | func (p *popupDestroySpy) DetachContent() *gtk.Widget { p.detached++; return >k.Widget{} } |
| 26 | - | } | 29 | + | func (p *popupDestroySpy) Close() { p.closed++ } |
| 30 | + | func (p *popupDestroySpy) Show() { p.shown++ } | |||
| 27 | 31 | ||||
| 28 | func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) { | 32 | func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) { | ||
| 29 | widget := layoutmocks.NewMockWidget(t) | 33 | widget := layoutmocks.NewMockWidget(t) | ||
| @@ -46,7 +50,7 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te | |||||
| 46 | assert.Nil(t, got) | 50 | assert.Nil(t, got) | ||
| 47 | } | 51 | } | ||
| 48 | 52 | ||||
| 49 | - | func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) { | 53 | + | func TestOpenNativePopupWindowLeavesWebViewOwnedByCallerOnSetupFailure(t *testing.T) { |
| 50 | oldNewPopupWindow := newPopupWindow | 54 | oldNewPopupWindow := newPopupWindow | ||
| 51 | t.Cleanup(func() { | 55 | t.Cleanup(func() { | ||
| 52 | newPopupWindow = oldNewPopupWindow | 56 | newPopupWindow = oldNewPopupWindow | ||
| @@ -58,23 +62,15 @@ func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *tes | |||||
| 58 | } | 62 | } | ||
| 59 | 63 | ||||
| 60 | wv := portmocks.NewMockWebView(t) | 64 | wv := portmocks.NewMockWebView(t) | ||
| 61 | - | wv.EXPECT().IsDestroyed().Return(false).Once() | |||
| 62 | - | wv.EXPECT().Destroy().Once() | |||
| 63 | - | ||||
| 64 | app := &App{gtkApp: >k.Application{}} | 65 | app := &App{gtkApp: >k.Application{}} | ||
| 65 | err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv}) | 66 | err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv}) | ||
| 66 | require.ErrorIs(t, err, expectedErr) | 67 | require.ErrorIs(t, err, expectedErr) | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | - | func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) { | 70 | + | func TestDestroyFailedNativePopupSetupDestroysOnlyShell(t *testing.T) { |
| 70 | - | wv := portmocks.NewMockWebView(t) | |||
| 71 | - | wv.EXPECT().IsDestroyed().Return(false).Once() | |||
| 72 | - | wv.EXPECT().Destroy().Once() | |||
| 73 | - | ||||
| 74 | shell := &popupDestroySpy{} | 71 | shell := &popupDestroySpy{} | ||
| 75 | - | destroyFailedNativePopupSetup(shell, wv) | 72 | + | destroyFailedNativePopupSetup(shell) |
| 76 | - | 73 | + | assert.Equal(t, 1, shell.destroyed) | |
| 77 | - | assert.True(t, shell.destroyed) | |||
| 78 | } | 74 | } | ||
| 79 | 75 | ||||
| 80 | func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) { | 76 | func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) { | ||
| @@ -104,19 +100,67 @@ func TestDispatchNativePopupLifecycleTreatsCompletedAfterTimeoutAsCompleted(t *t | |||||
| 104 | assert.True(t, ran) | 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 | wv := portmocks.NewMockWebView(t) | 138 | wv := portmocks.NewMockWebView(t) | ||
| 109 | wv.EXPECT().IsDestroyed().Return(false).Once() | 139 | wv.EXPECT().IsDestroyed().Return(false).Once() | ||
| 110 | wv.EXPECT().Destroy().Once() | 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 | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | 157 | app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{ | ||
| 113 | - | port.WebViewID(1): { | 158 | + | 1: {popupID: 1, webView: wv, popupWindow: shell}, |
| 114 | - | popupID: port.WebViewID(1), | |||
| 115 | - | webView: wv, | |||
| 116 | - | }, | |||
| 117 | }} | 159 | }} | ||
| 118 | 160 | ||||
| 119 | - | app.releaseNativePopupWindow(port.WebViewID(1), false, false) | 161 | + | app.releaseNativePopupWindow(1, nativePopupReleaseDestroy) |
| 120 | - | _, ok := app.nativePopupWindows[port.WebViewID(1)] | 162 | + | app.releaseNativePopupWindow(1, nativePopupReleaseDestroy) |
| 163 | + | _, ok := app.nativePopupWindows[1] | |||
| 121 | assert.False(t, ok) | 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 | pw.currentContent = widget | 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 | func (pw *PopupWindow) Show() { | 76 | func (pw *PopupWindow) Show() { | ||
| 65 | if pw == nil || pw.window == nil { | 77 | if pw == nil || pw.window == nil { | ||
| 66 | return | 78 | return | ||