detached 11ab80c

Merge pull request #363 from bnema/fix/floating-pane-browsing-contexts

fix(browsing): route floating pane browsing contexts safely
commit
11ab80cd8779122c9e0003f8037d13174fd58040
author
bnema · · 2026-07-27 11:58 UTC
committer
GitHub · · 2026-07-27 11:58 UTC
merge of
a405b8e + e37d7f0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
49 files · +4478 -1417
M .mockery.yaml +1 -0
@@ -85,6 +85,7 @@ packages:
8585 FaviconDatabase: {}
8686 IdleInhibitor: {}
8787 PopupLifecycleCapable: {}
88+ PopupFeatureResolver: {}
8889 PopupOpenerCapable: {}
8990 OAuthCallbackCapable: {}
9091 DevToolsOpener: {}
M cmd/dumber/main.go +9 -0
@@ -147,6 +147,15 @@ type startupTiming struct {
147147 }
148148
149149 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+
150159 // CEF is not known until configuration is complete. Keep these neutral
151160 // timestamps so a CEF GUI trace can be seeded truthfully without creating
152161 // 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
1616 - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView.
1717 - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state.
1818
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+
1925 ## Configuration
2026
2127 | Key | Type | Default | Description |
M go.mod +2 -2
@@ -6,8 +6,8 @@ require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.2
88 github.com/bnema/purego v0.11.0-bnema.4
9- github.com/bnema/purego-cef v0.14.1
10- github.com/bnema/purego-cef2gtk v0.8.5
9+ github.com/bnema/purego-cef v0.14.2
10+ github.com/bnema/purego-cef2gtk v0.9.0
1111 github.com/bnema/purego-pipewire v0.1.5
1212 github.com/bnema/purego-sqlite v0.1.4
1313 github.com/bnema/purego-webp v0.2.1
M go.sum +4 -4
@@ -12,10 +12,10 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
1212 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
1313 github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3kRprbs=
1414 github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg=
15-github.com/bnema/purego-cef v0.14.1 h1:ZAlrGfFyk3Eel0Gct+ipzJnnNfpXfDX8NuoZfRKAKco=
16-github.com/bnema/purego-cef v0.14.1/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg=
17-github.com/bnema/purego-cef2gtk v0.8.5 h1:Qwt9DkvCVBNN52dp1jikFU5B2Sg3dB16ayi/T4+2Bhg=
18-github.com/bnema/purego-cef2gtk v0.8.5/go.mod h1:w/5Bjkm+ZaZbDIDS+ysC5qTBcpb8/WOeGDva8i39ocM=
15+github.com/bnema/purego-cef v0.14.2 h1:0tKXT00gFvWUsL3A4sX4kzD+3XRmPOif0ccwO9iokqU=
16+github.com/bnema/purego-cef v0.14.2/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg=
17+github.com/bnema/purego-cef2gtk v0.9.0 h1:i1jdYIHFO6y6eAhzQ4Djy1t9z1GyH5ZrgR18f6C92Gc=
18+github.com/bnema/purego-cef2gtk v0.9.0/go.mod h1:EqgpNih2djBXD9j7R5gs+gZcdQxzSgkYF3rf7eC+5zo=
1919 github.com/bnema/purego-pipewire v0.1.5 h1:wQQF2uHt/kVV3qOkdSE1CV0noQYkHS2+v9MXkgdjxEw=
2020 github.com/bnema/purego-pipewire v0.1.5/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk=
2121 github.com/bnema/purego-sqlite v0.1.4 h1:h2DCsYT1/Ps13Ua4knUNQmT5Xgj6eRx+DrywNcnFNEU=
M internal/application/dto/browsing_context.go +93 -24
@@ -1,19 +1,56 @@
11 package dto
22
3-// WindowDisposition describes how the browsing context should be presented
4-// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
5-// dispositions at a normalized product seam.
3+// SourceHostKind identifies the product host that originated a browsing-context request.
4+type SourceHostKind string
5+
6+const (
7+ // SourceHostWorkspace is the default persisted workspace pane host.
8+ SourceHostWorkspace SourceHostKind = "workspace"
9+ // SourceHostFloating is a transient floating pane outside the workspace tree.
10+ SourceHostFloating SourceHostKind = "floating"
11+)
12+
13+// BrowserEngineKind identifies the browser engine that produced a normalized request.
14+type BrowserEngineKind string
15+
16+const (
17+ BrowserEngineUnknown BrowserEngineKind = "unknown"
18+ BrowserEngineCEF BrowserEngineKind = "cef"
19+ BrowserEngineWebKit BrowserEngineKind = "webkit"
20+)
21+
22+// PopupFeatureState describes whether popup metadata is available and explicit.
23+// Host policy treats its zero value as featureless, not PopupFeaturesUnknown;
24+// popup producers must set State explicitly when resolution must be deferred.
25+type PopupFeatureState string
26+
27+const (
28+ PopupFeaturesUnknown PopupFeatureState = "unknown"
29+ PopupFeaturesNone PopupFeatureState = "none"
30+ PopupFeaturesSpecified PopupFeatureState = "specified"
31+)
32+
33+// PopupFeatures is the engine-independent subset of window.open features used by host policy.
34+type PopupFeatures struct {
35+ State PopupFeatureState
36+
37+ X, Y, Width, Height int
38+ XSet, YSet, WidthSet, HeightSet bool
39+ ToolbarVisible, LocationbarVisible bool
40+ ToolbarVisibilitySet bool
41+ LocationbarVisibilitySet bool
42+ Resizable, ResizableSet bool
43+ IsPopup, IsPopupSet bool
44+}
45+
46+// WindowDisposition describes how the browsing context should be presented.
647 type WindowDisposition string
748
849 const (
9- // WindowDispositionCurrentTab replaces the current document in the same browsing context.
1050 WindowDispositionCurrentTab WindowDisposition = "current-tab"
11- // WindowDispositionNewTab opens in a new tab-like context.
12- WindowDispositionNewTab WindowDisposition = "new-tab"
13- // WindowDispositionNewPopup opens in a popup-style context.
14- WindowDispositionNewPopup WindowDisposition = "new-popup"
15- // WindowDispositionNewWindow opens in a new top-level window.
16- WindowDispositionNewWindow WindowDisposition = "new-window"
51+ WindowDispositionNewTab WindowDisposition = "new-tab"
52+ WindowDispositionNewPopup WindowDisposition = "new-popup"
53+ WindowDispositionNewWindow WindowDisposition = "new-window"
1754 )
1855
1956 // TriggerKind categorizes what triggered a browsing-context request.
@@ -27,22 +64,21 @@ const (
2764 TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
2865 )
2966
30-// RequestContextDisposition makes request-context inheritance explicit at the
31-// policy seam without leaking infrastructure request-context types upward.
67+// RequestContextDisposition makes request-context inheritance explicit at the policy seam.
3268 type RequestContextDisposition string
3369
3470 const (
35- // RequestContextInheritParent preserves the parent request context/session.
3671 RequestContextInheritParent RequestContextDisposition = "inherit-parent"
37- // RequestContextIsolate starts from a fresh unrelated request context.
38- RequestContextIsolate RequestContextDisposition = "isolate"
72+ RequestContextIsolate RequestContextDisposition = "isolate"
3973 )
4074
41-// NewBrowsingContextRequest is the normalized request captured from engine/UI
42-// signals before policy decides how it should be hosted.
75+// NewBrowsingContextRequest is the normalized request captured before host policy runs.
4376 type NewBrowsingContextRequest struct {
4477 ParentWebViewID uint64
4578
79+ Engine BrowserEngineKind
80+ SourceHost SourceHostKind
81+
4682 SourceBrowserID int32
4783 SourceFrameID string
4884 SourceFrameURL string
@@ -53,7 +89,7 @@ type NewBrowsingContextRequest struct {
5389
5490 IsUserGesture bool
5591 NoJavaScriptAccess bool
56- WindowFeatures string
92+ PopupFeatures PopupFeatures
5793
5894 TriggerKind TriggerKind
5995 AuthIntent bool
@@ -61,24 +97,57 @@ type NewBrowsingContextRequest struct {
6197 RequestContextDisposition RequestContextDisposition
6298 }
6399
64-// HostDecisionKind categorizes the approved host choices for a new browsing context.
100+// HostDecisionKind categorizes approved hosts for a new browsing context.
65101 type HostDecisionKind string
66102
67103 const (
68- HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
69- HostDecisionCreatePane HostDecisionKind = "create-pane"
70- HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
71- HostDecisionDeny HostDecisionKind = "deny"
104+ HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
105+ HostDecisionNavigateSource HostDecisionKind = "navigate-source"
106+ HostDecisionCreatePane HostDecisionKind = "create-pane"
107+ HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window"
108+ HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup"
109+ HostDecisionAwaitPopupFeatures HostDecisionKind = "await-popup-features"
110+ HostDecisionDeny HostDecisionKind = "deny"
111+)
112+
113+// HostDecisionReasonCode is a stable machine-readable policy explanation.
114+type HostDecisionReasonCode string
115+
116+const (
117+ HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target"
118+ HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup"
119+ HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required"
120+ HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native"
121+ HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse"
122+ HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending"
123+ HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features"
124+ HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank"
125+ HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window"
126+ HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane"
127+)
128+
129+// BrowsingContextFailureCode is a stable machine-readable operational failure.
130+type BrowsingContextFailureCode string
131+
132+const (
133+ BrowsingContextFailureHostUnavailable BrowsingContextFailureCode = "host-unavailable"
134+ BrowsingContextFailureHostFailed BrowsingContextFailureCode = "host-failed"
135+ BrowsingContextFailureFeatureResolution BrowsingContextFailureCode = "feature-resolution-failed"
136+ BrowsingContextFailureStagingDetach BrowsingContextFailureCode = "staging-detach-failed"
137+ BrowsingContextFailureNativeArm BrowsingContextFailureCode = "native-arm-failed"
138+ BrowsingContextFailureFallback BrowsingContextFailureCode = "fallback-failed"
72139 )
73140
74141 // HostDecision represents the output of browsing-context policy.
75142 type HostDecision struct {
76- Kind HostDecisionKind
143+ Kind HostDecisionKind
144+ SourceHost SourceHostKind
77145
78146 ReuseContextName string
79147 BrowsingContextName string
80148
81149 RequestContextDisposition RequestContextDisposition
82150 RequiresNativeOpener bool
151+ ReasonCode HostDecisionReasonCode
83152 Reason string
84153 }
M internal/application/port/mocks/webview.go +72 -0
@@ -7,6 +7,7 @@ package mocks
77 import (
88 "context"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 mock "github.com/stretchr/testify/mock"
1213 )
@@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop
26312632 return _c
26322633 }
26332634
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+
26342706 // 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.
26352707 // The first argument is typically a *testing.T value.
26362708 func NewMockPopupOpenerCapable(t interface {
M internal/application/port/webview.go +8 -1
@@ -77,8 +77,9 @@ type PopupRequest struct {
7777 SourceFrameID string
7878 SourceFrameURL string
7979
80+ Engine dto.BrowserEngineKind
8081 TargetDisposition dto.WindowDisposition
81- WindowFeatures string
82+ PopupFeatures dto.PopupFeatures
8283 }
8384
8485 // Texture represents a graphics texture returned by the engine.
@@ -342,6 +343,12 @@ type PopupLifecycleCapable interface {
342343 PrimePopupNavigation(uri string)
343344 }
344345
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+
345352 // PopupOpenerCapable is implemented by popup WebViews that can emulate opener
346353 // semantics when a popup must be created directly instead of through a native
347354 // 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) {
118118 binary := filepath.Join(temp, "dumber")
119119 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
120120
121+ const upstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe"
122+ const upstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
123+ goBin := collectorProvenanceGo(t, temp, upstreamVersion, upstreamRevision, "")
121124 output := filepath.Join(temp, "artifacts")
122125 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
123126 cmd.Dir = repoRoot
@@ -129,6 +132,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
129132 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
130133 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
131134 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
135+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
132136 )
133137 result, err := cmd.CombinedOutput()
134138 require.NoErrorf(t, err, "collector failed: %s", result)
@@ -144,9 +148,9 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
144148 }
145149 for _, required := range []string{
146150 `"measured_source_revision"`,
147- `"version": "v0.8.5"`,
151+ `"version": "` + upstreamVersion + `"`,
148152 `"tag": "v0.8.5"`,
149- `"revision": "b9fb46ef5c04ca0993943c5ca4b6f0fe83583304"`,
153+ `"revision": "` + upstreamRevision + `"`,
150154 } {
151155 require.Contains(t, artifacts.String(), required)
152156 }
M internal/infrastructure/cef/webview.go +24 -5
@@ -172,6 +172,7 @@ type WebView struct {
172172 // Programmatic popup lifecycle callbacks used for OAuth auto-close and
173173 // synthetic window.open() proxy support on CEF.
174174 closeCallbacks []func()
175+ popupLifecycleClose func()
175176 navigationCallbacks []func(string)
176177 openerMessageCallbacks []func()
177178 openerNavigationCallbacks []func(string)
@@ -653,9 +654,15 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
653654 }
654655 }
655656
656-// SetOnClose implements port.PopupLifecycleCapable.
657+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
658+// disturbing OAuth and other additive close callbacks.
657659 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()
659666 }
660667
661668 // Show implements port.PopupLifecycleCapable.
@@ -823,15 +830,23 @@ func (wv *WebView) SetNativePopupHostAbort(fn func()) {
823830 }
824831
825832 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 {
826839 if wv == nil {
827- return
840+ return false
828841 }
829842 wv.mu.RLock()
830843 fn := wv.nativePopupHostAbort
831844 wv.mu.RUnlock()
832- if fn != nil {
833- fn()
845+ if fn == nil {
846+ return false
834847 }
848+ fn()
849+ return true
835850 }
836851
837852 func (wv *WebView) awaitsNativePopupAttachment() bool {
@@ -2291,6 +2306,10 @@ func (wv *WebView) runCloseCallbacks() {
22912306 wv.mu.Lock()
22922307 callbacks := append([]func(){}, wv.closeCallbacks...)
22932308 wv.closeCallbacks = nil
2309+ if wv.popupLifecycleClose != nil {
2310+ callbacks = append(callbacks, wv.popupLifecycleClose)
2311+ wv.popupLifecycleClose = nil
2312+ }
22942313 wv.mu.Unlock()
22952314 if len(callbacks) == 0 {
22962315 return
M internal/infrastructure/cef/webview_handlers.go +96 -13
@@ -587,6 +587,30 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCo
587587 }
588588 }
589589
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+
590614 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
591615 switch disposition {
592616 case purecef.WindowOpenDispositionWodCurrentTab:
@@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind
616640 // pane handle the navigation.
617641 func (h *handlerSet) OnBeforePopup(
618642 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,
620644 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
621645 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
622646 ) bool {
@@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup(
649673
650674 dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() {
651675 popup = cb.OnCreate(port.PopupRequest{
676+ Engine: dto.BrowserEngineCEF,
677+ PopupFeatures: mapCEFPopupFeatures(popupFeatures),
652678 TargetURI: targetURL,
653679 FrameName: targetFrameName,
654680 IsUserGesture: userGesture != 0,
@@ -682,9 +708,33 @@ func (h *handlerSet) OnBeforePopup(
682708 }
683709 }
684710
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 {
685735 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
686736 switch decision.Kind {
687- case dto.HostDecisionCreateNativeWin:
737+ case dto.HostDecisionCreateNativePopup:
688738 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
689739 logging.FromContext(h.currentContext()).Debug().
690740 Int32("popup_id", popupID).
@@ -692,32 +742,65 @@ func (h *handlerSet) OnBeforePopup(
692742 Msg("cef: native popup armed")
693743 return false
694744 }
695- if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok {
696- aborter.AbortNativePopupHost()
745+
746+ fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup
747+ if fallbackEligible {
748+ // The Phase 3 abort callback may synchronously detach and adopt this
749+ // shell. Arm direct creation first so the adopted shell can create a
750+ // browser and retain the synthetic opener bridge when allowed.
751+ cefPopup.preparePopupShellDirectBrowserCreation()
752+ }
753+ abortInvoked := cefPopup.abortNativePopupHost()
754+ if !abortInvoked {
755+ cefPopup.Destroy()
697756 }
698- cefPopup.discardNativePopupCandidate()
699757 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)).
700763 Int32("popup_id", popupID).
701764 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")
703768 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+ }
705781 logging.FromContext(h.currentContext()).Debug().
706782 Int32("popup_id", popupID).
707783 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
708784 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")
710795 return true
711796 default:
712- if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
713- return false
797+ if !cefPopup.preparePopupShellDirectBrowserCreation() {
798+ cefPopup.discardNativePopupCandidate()
714799 }
715-
716- cefPopup.discardNativePopupCandidate()
717800 logging.FromContext(h.currentContext()).Debug().
718801 Int32("popup_id", popupID).
719802 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")
721804 return true
722805 }
723806 }
M internal/infrastructure/cef/webview_handlers_test.go +2 -115
@@ -3,7 +3,6 @@ package cef
33 import (
44 "context"
55 "sync"
6- "sync/atomic"
76 "testing"
87 "time"
98
@@ -229,6 +228,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe
229228 rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth")
230229 require.True(t, ok)
231230 require.NotNil(t, rawClient)
231+ require.Equal(t, int32(55), wv.nativePopupID)
232+ require.Same(t, parent, wv.nativePopupParent)
232233 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
233234
234235 wv.updateURI("about:blank")
@@ -262,120 +263,6 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin
262263 require.True(t, wv.isLoading)
263264 }
264265
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-
379266 func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) {
380267 windowInfo := purecef.NewWindowInfo()
381268 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)
3232 var _ port.DevToolsOpener = (*WebView)(nil)
3333 var _ port.Printer = (*WebView)(nil)
3434 var _ port.PopupLifecycleCapable = (*WebView)(nil)
35+var _ port.PopupFeatureResolver = (*WebView)(nil)
3536 var _ port.OAuthCallbackCapable = (*WebView)(nil)
3637
3738 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -50,10 +51,11 @@ const (
5051
5152 // PopupRequest contains information about a popup window request from the create signal.
5253 type PopupRequest struct {
53- TargetURI string
54- FrameName string // e.g., "_blank", custom name, or empty
55- IsUserGesture bool
56- ParentID WebViewID
54+ TargetURI string
55+ FrameName string // e.g., "_blank", custom name, or empty
56+ NavigationType webkit.NavigationType
57+ IsUserGesture bool
58+ ParentID WebViewID
5759 }
5860
5961 // webViewRegistry tracks all active WebViews.
@@ -154,6 +156,7 @@ type WebView struct {
154156 OnProgressChanged func(float64)
155157 OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
156158 OnClose func()
159+ popupLifecycleClose func()
157160 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
158161 OnReadyToShow func() // Called when popup is ready to display
159162 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
@@ -514,14 +517,50 @@ func (wv *WebView) connectWebProcessResponsiveSignal() {
514517
515518 func (wv *WebView) connectCloseSignal() {
516519 closeCb := func(_ webkit.WebView) {
517- if wv.OnClose != nil {
518- wv.OnClose()
519- }
520+ wv.runCloseCallbacks()
520521 }
521522 sigID := wv.inner.ConnectClose(&closeCb)
522523 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
523524 }
524525
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+
525564 func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
526565 wv.mu.Lock()
527566 wv.browsingContextDecision = decision
@@ -568,10 +607,11 @@ func (wv *WebView) connectCreateSignal() {
568607 }
569608
570609 popupReq := PopupRequest{
571- TargetURI: targetURI,
572- FrameName: navAction.GetFrameName(),
573- IsUserGesture: navAction.IsUserGesture(),
574- ParentID: wv.id,
610+ TargetURI: targetURI,
611+ FrameName: navAction.GetFrameName(),
612+ NavigationType: navAction.GetNavigationType(),
613+ IsUserGesture: navAction.IsUserGesture(),
614+ ParentID: wv.id,
575615 }
576616
577617 wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
@@ -594,9 +634,7 @@ func (wv *WebView) connectCreateSignal() {
594634
595635 func (wv *WebView) connectReadyToShowSignal() {
596636 readyToShowCb := func(_ webkit.WebView) {
597- if wv.OnReadyToShow != nil {
598- wv.OnReadyToShow()
599- }
637+ wv.fireReadyToShow()
600638 }
601639 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
602640 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
@@ -1624,27 +1662,55 @@ func (*WebView) PrimePopupNavigation(string) {}
16241662 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
16251663 // It implements port.PopupLifecycleCapable.
16261664 func (wv *WebView) SetOnReadyToShow(fn func()) {
1665+ if wv == nil {
1666+ return
1667+ }
1668+ wv.mu.Lock()
16271669 wv.OnReadyToShow = fn
1670+ wv.mu.Unlock()
16281671 }
16291672
1630-// SetOnClose composes fn with any existing OnClose handler so multiple callers
1631-// can each register a close callback without overwriting one another.
1632-// It implements port.PopupLifecycleCapable.
1673+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
1674+// disturbing WebView callbacks or additive OAuth close callbacks.
16331675 func (wv *WebView) SetOnClose(fn func()) {
1634- existing := wv.OnClose
1635- if existing == nil {
1636- wv.OnClose = fn
1676+ if wv == nil {
16371677 return
16381678 }
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 {
16401686 return
16411687 }
1642- wv.OnClose = func() {
1643- existing()
1688+ wv.mu.RLock()
1689+ fn := wv.OnReadyToShow
1690+ wv.mu.RUnlock()
1691+ if fn != nil {
16441692 fn()
16451693 }
16461694 }
16471695
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+
16481714 // State returns the current WebView state as a snapshot.
16491715 func (wv *WebView) State() port.WebViewState {
16501716 return port.WebViewState{
@@ -1658,6 +1724,26 @@ func (wv *WebView) State() port.WebViewState {
16581724 }
16591725 }
16601726
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+
16611747 // SetCallbacks registers callback handlers for WebView events.
16621748 // Pass nil to clear all callbacks.
16631749 func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
@@ -1696,13 +1782,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
16961782 wv.OnClose = callbacks.OnClose
16971783 if callbacks.OnCreate != nil {
16981784 wv.OnCreate = func(req PopupRequest) *WebView {
1699- portReq := port.PopupRequest{
1700- TargetURI: req.TargetURI,
1701- FrameName: req.FrameName,
1702- IsUserGesture: req.IsUserGesture,
1703- ParentViewID: req.ParentID,
1704- }
1705- result := callbacks.OnCreate(portReq)
1785+ result := callbacks.OnCreate(mapPopupRequest(req))
17061786 if result == nil {
17071787 return nil
17081788 }
@@ -1793,9 +1873,7 @@ func (wv *WebView) Close() {
17931873 if wv.destroyed.Load() {
17941874 return
17951875 }
1796- if wv.OnClose != nil {
1797- wv.OnClose()
1798- }
1876+ wv.runCloseCallbacks()
17991877 }
18001878
18011879 // AddCloseCallback implements port.OAuthCallbackCapable.
@@ -1931,6 +2009,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
19312009 wv.browsingContextDecision = dto.HostDecision{}
19322010 wv.hasBrowsingContextDecision = false
19332011 wv.nativePopupHostAbort = nil
2012+ wv.popupLifecycleClose = nil
19342013 wv.mu.Unlock()
19352014
19362015 // 4. Unparent from GTK hierarchy (must happen before process termination)
@@ -2001,6 +2080,7 @@ func (wv *WebView) ResetForPoolReuse() {
20012080 wv.browsingContextDecision = dto.HostDecision{}
20022081 wv.hasBrowsingContextDecision = false
20032082 wv.nativePopupHostAbort = nil
2083+ wv.popupLifecycleClose = nil
20042084 wv.lastProgressUpdate.Store(0)
20052085 wv.mu.Unlock()
20062086
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) {
1111 wv := &WebView{}
1212 abortCalled := false
1313
14- wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
14+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
1515 wv.SetNativePopupHostAbort(func() { abortCalled = true })
1616
1717 wv.ResetForPoolReuse()
M internal/ui/app.go +57 -33
@@ -13,6 +13,7 @@ import (
1313 "time"
1414 "unsafe"
1515
16+ "github.com/bnema/dumber/internal/application/dto"
1617 "github.com/bnema/dumber/internal/application/port"
1718 "github.com/bnema/dumber/internal/application/usecase"
1819 "github.com/bnema/dumber/internal/domain/entity"
@@ -614,6 +615,20 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.
614615 return a.lastFocusedBrowserWindow()
615616 }
616617
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+
617632 func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
618633 bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
619634 if bw == nil {
@@ -2941,13 +2956,16 @@ func (a *App) onShutdown(ctx context.Context) {
29412956 for _, tabID := range tabIDs {
29422957 a.releaseFloatingSessionsForTab(ctx, tabID)
29432958 }
2959+ if a.contentCoord != nil {
2960+ a.contentCoord.ShutdownPopups()
2961+ }
29442962 if len(a.nativePopupWindows) > 0 {
29452963 popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows))
29462964 for popupID := range a.nativePopupWindows {
29472965 popupIDs = append(popupIDs, popupID)
29482966 }
29492967 for _, popupID := range popupIDs {
2950- a.releaseNativePopupWindow(popupID, false, true)
2968+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
29512969 }
29522970 }
29532971 if a.engine != nil {
@@ -3077,9 +3095,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
30773095 }
30783096 })
30793097 // Wire popup tab WebView attachment
3080- a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
3081- a.attachPopupToTab(ctx, tabID, pane, wv)
3082- })
3098+ a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)
30833099
30843100 for _, bw := range a.browserWindows {
30853101 a.wireBrowserWindowTabBar(ctx, bw)
@@ -3157,13 +3173,15 @@ func (a *App) initCoordinators(ctx context.Context) {
31573173 &runtimeCfg.Workspace.BrowsingContexts,
31583174 a.generateID,
31593175 )
3160- a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
3161- bw := a.browserWindowForAnyPane(paneID)
3162- if bw == nil {
3163- return "", false
3176+ a.contentCoord.SetPopupSourceHostResolver(func(paneID entity.PaneID) dto.SourceHostKind {
3177+ if a.floatingSessionByPaneID(paneID) != nil {
3178+ return dto.SourceHostFloating
31643179 }
3165- return bw.id, true
3180+ return dto.SourceHostWorkspace
31663181 })
3182+ a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane)
3183+ a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow)
3184+ a.contentCoord.SetOnStagePopup(a.stagePopup)
31673185 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
31683186 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
31693187 a.activateBrowserWindow(bw)
@@ -3176,7 +3194,7 @@ func (a *App) initCoordinators(ctx context.Context) {
31763194 }
31773195 return a.wsCoord.ClosePaneByID(ctx, paneID)
31783196 })
3179- a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow)
3197+ a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow)
31803198 // Wire tabbed popup behavior to create new tabs in the originating window.
31813199 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
31823200
@@ -3930,55 +3948,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
39303948
39313949 // attachPopupToTab attaches a popup WebView to a newly created tab.
39323950 // 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 {
39343952 log := logging.FromContext(ctx)
3935- cleanupUnattachedPopup := func() {
3936- if wv != nil {
3937- wv.Destroy()
3938- }
3939- }
3940-
39413953 wsView := a.workspaceViews[tabID]
39423954 if wsView == nil {
3943- cleanupUnattachedPopup()
39443955 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)
39463957 }
39473958 if pane == nil {
3948- cleanupUnattachedPopup()
39493959 log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")
3950- return
3960+ return fmt.Errorf("popup pane is nil")
39513961 }
39523962 if wsView.GetPaneView(pane.ID) == nil {
3953- cleanupUnattachedPopup()
39543963 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)
39563965 }
39573966 if a.contentCoord == nil {
3958- cleanupUnattachedPopup()
39593967 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")
39613969 }
39623970
3963- // Register WebView with content coordinator.
3964- a.contentCoord.RegisterPopupWebView(pane.ID, wv)
3965-
3966- // Wrap and attach widget.
3971+ // Wrap and attach before registration so a failed attachment leaves the
3972+ // caller-owned WebView untouched and unregistered.
39673973 widget := a.contentCoord.WrapWidget(ctx, wv)
39683974 if widget == nil {
3969- a.contentCoord.ReleaseWebView(ctx, pane.ID)
39703975 log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")
3971- return
3976+ return fmt.Errorf("wrap popup webview")
39723977 }
39733978 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3974- a.contentCoord.ReleaseWebView(ctx, pane.ID)
39753979 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)
39773981 }
3982+ a.contentCoord.RegisterPopupWebView(pane.ID, wv)
39783983 log.Debug().
39793984 Str("tab_id", string(tabID)).
39803985 Str("pane_id", string(pane.ID)).
39813986 Msg("popup webview attached to tab")
3987+ return nil
39823988 }
39833989
39843990 // switchWorkspaceView swaps the displayed workspace view for a tab.
@@ -4584,6 +4590,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe
45844590 return nil
45854591 }
45864592
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+
45874611 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
45884612 if session == nil || session.overlay == nil || session.resizeWatcherActive {
45894613 return
M internal/ui/app_browser_launch_test.go +33 -14
@@ -1313,6 +1313,24 @@ func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
13131313 }
13141314 }
13151315
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+
13161334 func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
13171335 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
13181336 newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -2279,6 +2297,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
22792297 mainWindow: first.mainWindow,
22802298 }
22812299 app.initTabCoordinator(ctx)
2300+ app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
22822301
22832302 err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
22842303 ParentPaneID: secondTab.Workspace.ActivePaneID,
@@ -2538,28 +2557,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f
25382557 func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 }
25392558 func (f *recordingWebView) Destroy() { f.destroyCalls++ }
25402559
2541-func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) {
2560+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) {
25422561 ctx := context.Background()
25432562 popupWV := &recordingWebView{id: 1}
25442563 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
25452564
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)
25472566
2548- if popupWV.destroyCalls != 1 {
2549- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2567+ if err == nil || popupWV.destroyCalls != 0 {
2568+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25502569 }
25512570 }
25522571
2553-func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) {
2572+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) {
25542573 ctx := context.Background()
25552574 popupWV := &recordingWebView{id: 1}
25562575 tabID := entity.TabID("tab-1")
25572576 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
25582577
2559- app.attachPopupToTab(ctx, tabID, nil, popupWV)
2578+ err := app.attachPopupToTab(ctx, tabID, nil, popupWV)
25602579
2561- if popupWV.destroyCalls != 1 {
2562- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2580+ if err == nil || popupWV.destroyCalls != 0 {
2581+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25632582 }
25642583 }
25652584
@@ -2576,13 +2595,13 @@ func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T)
25762595 }
25772596
25782597 popupWV := &recordingWebView{id: 1}
2579- app.attachPopupToTab(ctx, tabID, pane, popupWV)
2598+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
25802599
25812600 if got := contentCoord.GetWebView(pane.ID); got != nil {
25822601 t.Fatalf("popup webview was registered for missing pane view: %v", got)
25832602 }
2584- if popupWV.destroyCalls != 1 {
2585- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2603+ if err == nil || popupWV.destroyCalls != 0 {
2604+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25862605 }
25872606 }
25882607
@@ -2617,13 +2636,13 @@ func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
26172636 }
26182637 popupWV := &recordingWebView{id: 1}
26192638
2620- app.attachPopupToTab(ctx, tabID, pane, popupWV)
2639+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
26212640
26222641 if got := contentCoord.GetWebView(pane.ID); got != nil {
26232642 t.Fatalf("popup webview registration remained after wrap failure: %v", got)
26242643 }
2625- if popupWV.destroyCalls != 1 {
2626- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2644+ if err == nil || popupWV.destroyCalls != 0 {
2645+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
26272646 }
26282647 }
26292648
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) {
366366 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
367367
368368 a.removeBrowserWindowOrder(id)
369+ a.releaseNativePopupsForBrowserWindow(id)
369370 a.releaseBrowserWindowTabs(context.Background(), id, removed)
370371 if removed != nil {
371372 removed.teardownForDestroy()
@@ -382,6 +383,14 @@ func (a *App) removeBrowserWindow(id string) {
382383 }
383384 }
384385
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+
385394 func (a *App) removeBrowserWindowOrder(id string) {
386395 for i, wid := range a.browserWindowOrder {
387396 if wid == id {
M internal/ui/browser_window_test.go +26 -0
@@ -8,6 +8,7 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -274,6 +275,31 @@ func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *t
274275 assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
275276 }
276277
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+
277303 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
278304 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
279305 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 (
55
66 "github.com/bnema/dumber/internal/application/dto"
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/rs/zerolog"
810 )
911
10-type browsingContextPolicy struct{}
11-
12-func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
13- decision := dto.HostDecision{
14- RequestContextDisposition: req.RequestContextDisposition,
15- RequiresNativeOpener: req.RequiresNativeOpener,
16- }
17- if decision.RequestContextDisposition == "" {
18- decision.RequestContextDisposition = dto.RequestContextInheritParent
19- }
20-
21- if strings.TrimSpace(req.TargetURI) == "" {
22- decision.Kind = dto.HostDecisionDeny
23- decision.Reason = "empty target URI"
24- return decision
25- }
26-
27- if req.AuthIntent {
28- decision.Kind = dto.HostDecisionCreateNativeWin
29- decision.Reason = "auth intent requires native window"
30- return decision
31- }
32- if req.RequiresNativeOpener {
33- decision.Kind = dto.HostDecisionCreateNativeWin
34- decision.Reason = "native opener required"
35- return decision
36- }
37- if isAmbiguousNativeBrowsingContext(req) {
38- decision.Kind = dto.HostDecisionCreateNativeWin
39- decision.Reason = "ambiguous opener-coupled request prefers native window"
40- return decision
41- }
42-
43- name := reusableBrowsingContextName(req.TargetFrameName)
44- if namedContextExists && name != "" {
45- decision.Kind = dto.HostDecisionReuseNamedPane
46- decision.ReuseContextName = name
47- decision.BrowsingContextName = name
48- decision.Reason = "reuse named browsing context in current window"
49- return decision
50- }
12+func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) {
13+ logger.Debug().
14+ Str("engine", string(req.Engine)).
15+ Str("source_host", string(req.SourceHost)).
16+ Str("decision", string(decision.Kind)).
17+ Str("target_disposition", string(req.TargetDisposition)).
18+ Str("reason_code", string(decision.ReasonCode)).
19+ Msg("browsing context host decided")
20+}
5121
52- decision.Kind = dto.HostDecisionCreatePane
53- decision.BrowsingContextName = name
54- decision.Reason = "pane-hosted browsing context"
55- return decision
22+func logBrowsingContextFailure(
23+ logger zerolog.Logger,
24+ req dto.NewBrowsingContextRequest,
25+ decision dto.HostDecision,
26+ code dto.BrowsingContextFailureCode,
27+ err error,
28+) {
29+ logger.Error().Err(err).
30+ Str("engine", string(req.Engine)).
31+ Str("source_host", string(req.SourceHost)).
32+ Str("decision", string(decision.Kind)).
33+ Str("target_disposition", string(req.TargetDisposition)).
34+ Str("reason_code", string(code)).
35+ Msg("browsing context hosting failed")
5636 }
5737
5838 func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest {
5939 return dto.NewBrowsingContextRequest{
6040 ParentWebViewID: uint64(req.ParentViewID),
41+ Engine: req.Engine,
6142 SourceBrowserID: req.SourceBrowserID,
6243 SourceFrameID: req.SourceFrameID,
6344 SourceFrameURL: req.SourceFrameURL,
@@ -66,7 +47,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont
6647 TargetDisposition: inferPopupWindowDisposition(req),
6748 IsUserGesture: req.IsUserGesture,
6849 NoJavaScriptAccess: req.NoJavaScriptAccess,
69- WindowFeatures: req.WindowFeatures,
50+ PopupFeatures: req.PopupFeatures,
7051 TriggerKind: inferPopupTriggerKind(req),
7152 AuthIntent: IsOAuthURL(req.TargetURI),
7253 RequiresNativeOpener: false,
@@ -99,38 +80,15 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
9980 }
10081
10182 func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
83+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
84+ return dto.TriggerNamedTargetNavigation
85+ }
10286 switch inferPopupWindowDisposition(req) {
103- case dto.WindowDispositionCurrentTab:
104- if reusableBrowsingContextName(req.FrameName) != "" {
105- return dto.TriggerNamedTargetNavigation
106- }
107- return dto.TriggerLinkNewPage
108- case dto.WindowDispositionNewTab:
109- if reusableBrowsingContextName(req.FrameName) != "" {
110- return dto.TriggerNamedTargetNavigation
111- }
87+ case dto.WindowDispositionCurrentTab, dto.WindowDispositionNewTab:
11288 return dto.TriggerLinkNewPage
11389 case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
114- if reusableBrowsingContextName(req.FrameName) != "" {
115- return dto.TriggerNamedTargetNavigation
116- }
11790 return dto.TriggerScriptWindowOpen
11891 default:
119- if reusableBrowsingContextName(req.FrameName) != "" {
120- return dto.TriggerNamedTargetNavigation
121- }
12292 return dto.TriggerUnknown
12393 }
12494 }
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 (
88 "github.com/stretchr/testify/assert"
99 )
1010
11-func TestBrowsingContextPolicyDecide(t *testing.T) {
11+func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) {
1212 t.Parallel()
1313
14- policy := browsingContextPolicy{}
15- tests := []struct {
16- name string
17- request dto.NewBrowsingContextRequest
18- namedContextExist bool
19- wantKind dto.HostDecisionKind
20- wantName string
21- }{
22- {
23- name: "blank target stays pane-hosted",
24- request: dto.NewBrowsingContextRequest{
25- TargetURI: "https://example.com/docs",
26- TargetFrameName: "_blank",
27- TriggerKind: dto.TriggerLinkNewPage,
28- TargetDisposition: dto.WindowDispositionNewTab,
29- RequestContextDisposition: dto.RequestContextInheritParent,
30- },
31- wantKind: dto.HostDecisionCreatePane,
32- },
33- {
34- name: "named target reuses existing pane in same window",
35- request: dto.NewBrowsingContextRequest{
36- TargetURI: "https://example.com/second",
37- TargetFrameName: "shared-pane",
38- TriggerKind: dto.TriggerNamedTargetNavigation,
39- TargetDisposition: dto.WindowDispositionNewPopup,
40- RequestContextDisposition: dto.RequestContextInheritParent,
41- },
42- namedContextExist: true,
43- wantKind: dto.HostDecisionReuseNamedPane,
44- wantName: "shared-pane",
45- },
46- {
47- name: "ordinary window.open stays pane-hosted",
48- request: dto.NewBrowsingContextRequest{
49- TargetURI: "https://example.com/help",
50- TriggerKind: dto.TriggerScriptWindowOpen,
51- TargetDisposition: dto.WindowDispositionNewPopup,
52- NoJavaScriptAccess: false,
53- RequestContextDisposition: dto.RequestContextInheritParent,
54- },
55- wantKind: dto.HostDecisionCreatePane,
56- },
57- {
58- name: "auth intent forces native window",
59- request: dto.NewBrowsingContextRequest{
60- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
61- TriggerKind: dto.TriggerScriptWindowOpen,
62- TargetDisposition: dto.WindowDispositionNewPopup,
63- AuthIntent: true,
64- RequestContextDisposition: dto.RequestContextInheritParent,
65- },
66- wantKind: dto.HostDecisionCreateNativeWin,
67- },
68- {
69- name: "ambiguous opener-coupled request prefers native",
70- request: dto.NewBrowsingContextRequest{
71- TargetURI: "https://example.com/popup",
72- TriggerKind: dto.TriggerUnknown,
73- TargetDisposition: dto.WindowDispositionNewPopup,
74- NoJavaScriptAccess: false,
75- RequestContextDisposition: dto.RequestContextInheritParent,
76- },
77- wantKind: dto.HostDecisionCreateNativeWin,
78- },
79- {
80- name: "empty target is denied",
81- request: dto.NewBrowsingContextRequest{
82- TargetURI: "",
83- TriggerKind: dto.TriggerLinkNewPage,
84- RequestContextDisposition: dto.RequestContextInheritParent,
85- },
86- wantKind: dto.HostDecisionDeny,
87- },
88- }
89-
90- for _, tt := range tests {
91- t.Run(tt.name, func(t *testing.T) {
92- t.Parallel()
93- got := policy.Decide(tt.request, tt.namedContextExist)
94- assert.Equal(t, tt.wantKind, got.Kind)
95- assert.Equal(t, tt.wantName, got.BrowsingContextName)
96- assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
97- })
98- }
99-}
100-
101-func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) {
102- t.Parallel()
103-
104- req := port.PopupRequest{
105- TargetURI: "https://example.com/window-open",
106- NoJavaScriptAccess: false,
107- TargetDisposition: dto.WindowDispositionNewPopup,
108- }
109-
110- assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req))
14+ features := dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 320, WidthSet: true}
15+ got := buildPopupBrowsingContextRequest(port.PopupRequest{Engine: dto.BrowserEngineCEF, PopupFeatures: features, TargetURI: "https://example.com", ParentViewID: 42})
16+ assert.Equal(t, dto.BrowserEngineCEF, got.Engine)
17+ assert.Equal(t, features, got.PopupFeatures)
11118 }
11219
113-func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
20+func TestInferPopupTriggerKind(t *testing.T) {
11421 t.Parallel()
115-
116- tests := []struct {
117- name string
118- req port.PopupRequest
119- want dto.TriggerKind
120- }{
121- {
122- name: "unnamed current-tab request stays a navigation",
123- req: port.PopupRequest{
124- TargetURI: "https://example.com/docs",
125- TargetDisposition: dto.WindowDispositionCurrentTab,
126- },
127- want: dto.TriggerLinkNewPage,
128- },
129- {
130- name: "named current-tab request is treated as named target navigation",
131- req: port.PopupRequest{
132- TargetURI: "https://example.com/docs",
133- FrameName: "shared-pane",
134- TargetDisposition: dto.WindowDispositionCurrentTab,
135- },
136- want: dto.TriggerNamedTargetNavigation,
137- },
138- }
139-
140- for _, tt := range tests {
141- t.Run(tt.name, func(t *testing.T) {
142- t.Parallel()
143- assert.Equal(t, tt.want, inferPopupTriggerKind(tt.req))
144- })
145- }
22+ assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(port.PopupRequest{TargetURI: "https://example.com/window-open", TargetDisposition: dto.WindowDispositionNewPopup}))
23+ assert.Equal(t, dto.TriggerLinkNewPage, inferPopupTriggerKind(port.PopupRequest{TargetDisposition: dto.WindowDispositionCurrentTab}))
24+ assert.Equal(t, dto.TriggerNamedTargetNavigation, inferPopupTriggerKind(port.PopupRequest{FrameName: "shared", TargetDisposition: dto.WindowDispositionCurrentTab}))
14625 }
14726
148-func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) {
27+func TestInferPopupWindowDisposition(t *testing.T) {
14928 t.Parallel()
150-
151- tests := []struct {
152- name string
153- req port.PopupRequest
154- want dto.WindowDisposition
155- }{
156- {
157- name: "mixed-case blank target is treated as new tab",
158- req: port.PopupRequest{FrameName: " _BlAnK "},
159- want: dto.WindowDispositionNewTab,
160- },
161- {
162- name: "named target still defaults to popup",
163- req: port.PopupRequest{FrameName: "shared-pane"},
164- want: dto.WindowDispositionNewPopup,
165- },
166- }
167-
168- for _, tt := range tests {
169- t.Run(tt.name, func(t *testing.T) {
170- t.Parallel()
171- assert.Equal(t, tt.want, inferPopupWindowDisposition(tt.req))
172- })
173- }
29+ assert.Equal(t, dto.WindowDispositionNewTab, inferPopupWindowDisposition(port.PopupRequest{FrameName: " _BlAnK "}))
30+ assert.Equal(t, dto.WindowDispositionNewPopup, inferPopupWindowDisposition(port.PopupRequest{FrameName: "shared-pane"}))
17431 }
M internal/ui/coordinator/content/lifecycle.go +1 -0
@@ -48,6 +48,7 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
4848 // ReleaseWebView returns the WebView for a pane to the pool.
4949 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
5050 log := logging.FromContext(ctx)
51+ c.ensurePopupManager().cancelDeferredPopupsForParent(paneID)
5152
5253 // deleteWebViewLocked clears reveal state before returning, even when a
5354 // 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
223223 return "", false
224224 })
225225 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())
227227
228228 _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
229229 require.True(t, okBefore)
M internal/ui/coordinator/content/named_context_registry.go +13 -13
@@ -8,13 +8,14 @@ import (
88 )
99
1010 type namedBrowsingContextKey struct {
11- WindowID string
12- Name string
11+ OwnerWindowID string
12+ Name string
1313 }
1414
1515 type namedBrowsingContextState struct {
16- PaneID entity.PaneID
17- WebViewID port.WebViewID
16+ PaneID entity.PaneID
17+ WebViewID port.WebViewID
18+ HostWindowID string
1819 }
1920
2021 type namedBrowsingContextRegistry struct {
@@ -28,14 +29,13 @@ func newNamedBrowsingContextRegistry() *namedBrowsingContextRegistry {
2829 }
2930 }
3031
31-func (r *namedBrowsingContextRegistry) Register(windowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
32- if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 {
32+func (r *namedBrowsingContextRegistry) Register(ownerWindowID, hostWindowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
33+ if r == nil || ownerWindowID == "" || hostWindowID == "" || name == "" || paneID == "" || webViewID == 0 {
3334 return
3435 }
3536 r.mu.Lock()
36- r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{
37- PaneID: paneID,
38- WebViewID: webViewID,
37+ r.contexts[namedBrowsingContextKey{OwnerWindowID: ownerWindowID, Name: name}] = namedBrowsingContextState{
38+ PaneID: paneID, WebViewID: webViewID, HostWindowID: hostWindowID,
3939 }
4040 r.mu.Unlock()
4141 }
@@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
4949 return namedBrowsingContextState{}, nil, false
5050 }
5151
52- key := namedBrowsingContextKey{WindowID: windowID, Name: name}
52+ key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name}
5353 r.mu.RLock()
5454 state, ok := r.contexts[key]
5555 r.mu.RUnlock()
@@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
5858 }
5959
6060 currentWindowID, ok := resolveWindowID(state.PaneID)
61- if !ok || currentWindowID != windowID {
61+ if !ok || currentWindowID != state.HostWindowID {
6262 r.deleteIfStateMatches(key, state)
6363 return namedBrowsingContextState{}, nil, false
6464 }
@@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) {
114114 return
115115 }
116116 r.mu.Lock()
117- for key := range r.contexts {
118- if key.WindowID == windowID {
117+ for key, state := range r.contexts {
118+ if key.OwnerWindowID == windowID || state.HostWindowID == windowID {
119119 delete(r.contexts, key)
120120 }
121121 }
M internal/ui/coordinator/content/named_context_registry_test.go +37 -7
@@ -15,7 +15,7 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
1515 reg := newNamedBrowsingContextRegistry()
1616 paneID := entity.PaneID("pane-1")
1717 webViewID := port.WebViewID(41)
18- reg.Register("window-1", "shared-pane", paneID, webViewID)
18+ reg.Register("window-1", "window-1", "shared-pane", paneID, webViewID)
1919
2020 wv := mocks.NewMockWebView(t)
2121 wv.EXPECT().IsDestroyed().Return(false).Once()
@@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
3939 assert.Same(t, wv, gotWV)
4040 }
4141
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+
4272 func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) {
4373 t.Parallel()
4474
4575 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))
4777
4878 _, _, ok := reg.Lookup(
4979 "window-2",
@@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi
6090
6191 reg := newNamedBrowsingContextRegistry()
6292 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))
6494
6595 _, _, ok := reg.Lookup(
6696 "window-1",
@@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
86116 oldPaneID := entity.PaneID("pane-old")
87117 newPaneID := entity.PaneID("pane-new")
88118 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))
90120
91121 _, _, ok := reg.Lookup(
92122 "window-1",
@@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
94124 func(entity.PaneID) port.WebView { return nil },
95125 func(gotPaneID entity.PaneID) (string, bool) {
96126 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)
98128 return "window-2", true
99129 },
100130 )
@@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS
128158 oldPaneID := entity.PaneID("pane-old")
129159 newPaneID := entity.PaneID("pane-new")
130160 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))
132162
133163 _, _, ok := reg.Lookup(
134164 "window-1",
135165 "shared-pane",
136166 func(gotPaneID entity.PaneID) port.WebView {
137167 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)
139169 return nil
140170 },
141171 func(gotPaneID entity.PaneID) (string, bool) {
M internal/ui/coordinator/content/popup.go +59 -8
@@ -4,6 +4,7 @@ import (
44 "context"
55 "time"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/logging"
@@ -105,14 +106,45 @@ type InsertPopupInput struct {
105106
106107 // NativePopupInput contains the data needed to host a native-required popup in
107108 // 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+
108136 type NativePopupInput struct {
109- ParentPaneID entity.PaneID
110- ParentWebViewID port.WebViewID
111- ParentURIAtOpen string
112- PopupWebView port.WebView
113- TargetURI string
114- Request port.PopupRequest
115- ObserveOAuthAutoClose bool
137+ ParentPaneID entity.PaneID
138+ ParentWebViewID port.WebViewID
139+ ParentURIAtOpen string
140+ PopupWebView port.WebView
141+ TargetURI string
142+ Request port.PopupRequest
143+ ObserveOAuthAutoClose bool
144+ AllowBrowserWindowFallback bool
145+ // OnNativeHostAbort returns true when fallback adopted the WebView. A false
146+ // result leaves destruction responsibility with the native host.
147+ OnNativeHostAbort func(context.Context, port.WebView) bool
116148 }
117149
118150 // 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
159191 c.ensurePopupManager().setWindowIDResolver(fn)
160192 }
161193
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+
162206 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()
164215 }
165216
166217 // 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 (
99
1010 "github.com/bnema/dumber/internal/application/dto"
1111 "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/logging"
1415 )
@@ -17,20 +18,34 @@ import (
1718 // adapter layer so popup pane bookkeeping and workspace orchestration do not
1819 // leak into the application/usecase or domain layers.
1920 type popupManager struct {
20- factory port.WebViewFactory
21- popupConfig entity.BrowsingContextConfig
22- hasPopupConfig bool
23- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24- onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
25- onClosePane func(ctx context.Context, paneID entity.PaneID) error
26- generatePaneID func() string
27- windowIDForPane func(entity.PaneID) (string, bool)
28- policy browsingContextPolicy
29- namedContexts *namedBrowsingContextRegistry
30- pendingPopups map[port.WebViewID]*PendingPopup
31- popupOAuth map[port.WebViewID]*popupOAuthState
32- popupRefresh map[entity.PaneID]*time.Timer
33- mu sync.RWMutex
21+ factory port.WebViewFactory
22+ popupConfig entity.BrowsingContextConfig
23+ hasPopupConfig bool
24+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
25+ onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error)
26+ onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
27+ onStagePopup func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error)
28+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
29+ generatePaneID func() string
30+ windowIDForPane func(entity.PaneID) (string, bool)
31+ sourceHostForPane func(entity.PaneID) dto.SourceHostKind
32+ policy usecase.BrowsingContextPolicy
33+ namedContexts *namedBrowsingContextRegistry
34+ pendingPopups map[port.WebViewID]*PendingPopup
35+ deferredPopups map[port.WebViewID]*deferredPendingPopup
36+ popupOAuth map[port.WebViewID]*popupOAuthState
37+ popupRefresh map[entity.PaneID]*time.Timer
38+ mu sync.RWMutex
39+}
40+
41+type deferredPendingPopup struct {
42+ *PendingPopup
43+ Request port.PopupRequest
44+ Decision dto.HostDecision
45+ StagingHost PopupStagingHost
46+ OwnerWindowID string
47+ ParentWebView port.WebView
48+ cleanupOnce sync.Once
3449 }
3550
3651 type popupOAuthState struct {
@@ -83,6 +98,9 @@ func (pm *popupManager) ensureInitialized() {
8398 if pm.pendingPopups == nil {
8499 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
85100 }
101+ if pm.deferredPopups == nil {
102+ pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup)
103+ }
86104 if pm.namedContexts == nil {
87105 pm.namedContexts = newNamedBrowsingContextRegistry()
88106 }
@@ -143,6 +161,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool
143161 pm.windowIDForPane = fn
144162 }
145163
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+
146208 func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
147209 behavior := GetBehavior(PopupTypeTab, cfg)
148210 placement := "right"
@@ -209,7 +271,7 @@ func (pm *popupManager) lookupReusableNamedPopup(
209271 return nil, false
210272 }
211273
212- name := reusableBrowsingContextName(frameName)
274+ name := usecase.ReusableBrowsingContextName(frameName)
213275 if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
214276 return nil, false
215277 }
@@ -227,18 +289,32 @@ func (pm *popupManager) lookupReusableNamedPopup(
227289 }
228290
229291 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 {
231293 return
232294 }
233- name := reusableBrowsingContextName(frameName)
234- if name == "" || pm.windowIDForPane == nil {
295+ hostWindowID, ok := pm.windowIDForPane(paneID)
296+ if !ok {
235297 return
236298 }
237- windowID, ok := pm.windowIDForPane(parentPaneID)
238- if !ok || windowID == "" {
299+ pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID)
300+}
301+
302+func (pm *popupManager) storeReusableNamedPopupWithHost(
303+ parentPaneID entity.PaneID,
304+ frameName string,
305+ paneID entity.PaneID,
306+ wv port.WebView,
307+ hostWindowID string,
308+) {
309+ if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil {
239310 return
240311 }
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())
242318 }
243319
244320 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
@@ -295,6 +371,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup,
295371 return pending, ok
296372 }
297373
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+
298396 func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
299397 if pm == nil {
300398 return
@@ -451,45 +549,6 @@ func (pm *popupManager) popupParentURIAtOpen(
451549 return parentURIAtOpen
452550 }
453551
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-
493552 func (pm *popupManager) handlePopupCreate(
494553 ctx context.Context,
495554 hooks popupCoordinatorHooks,
@@ -499,21 +558,8 @@ func (pm *popupManager) handlePopupCreate(
499558 ) port.WebView {
500559 log := logging.FromContext(ctx)
501560
502- log.Debug().
503- Str("parent_pane", string(parentPaneID)).
504- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
505- Str("frame_name", req.FrameName).
506- Bool("user_gesture", req.IsUserGesture).
507- Bool("no_javascript_access", req.NoJavaScriptAccess).
508- Msg("popup create request")
509-
510- cfg := pm.currentPopupConfig()
511- if cfg != nil && !cfg.OpenInNewPane {
512- log.Debug().Msg("popups disabled by config, blocking")
513- return nil
514- }
515- if pm.factory == nil {
516- log.Warn().Msg("no webview factory, cannot create popup")
561+ cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
562+ if !routingEnabled {
517563 return nil
518564 }
519565
@@ -521,16 +567,13 @@ func (pm *popupManager) handlePopupCreate(
521567 parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
522568
523569 request := buildPopupBrowsingContextRequest(req)
570+ request.SourceHost = sourceHost
524571 namedContextExists := false
525572 if !req.NoJavaScriptAccess {
526573 _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
527574 }
528575 decision := pm.policy.Decide(request, namedContextExists)
529- log.Debug().
530- Str("decision", string(decision.Kind)).
531- Str("reason", decision.Reason).
532- Str("context_name", decision.BrowsingContextName).
533- Msg("browsing context host decision")
576+ logBrowsingContextDecision(*log, request, decision)
534577
535578 switch decision.Kind {
536579 case dto.HostDecisionDeny:
@@ -543,14 +586,24 @@ func (pm *popupManager) handlePopupCreate(
543586 reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI)
544587 if ok {
545588 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
547593 }
548594 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
549- decision.Kind = dto.HostDecisionCreatePane
550- decision.ReuseContextName = ""
551- decision.Reason = "named browsing context unavailable; creating replacement pane"
552- case dto.HostDecisionCreateNativeWin:
553- return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
595+ // Lookup removes the stale registry entry. Re-enter policy once so the
596+ // replacement host remains source-aware instead of forcing a workspace pane.
597+ return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req)
598+ case dto.HostDecisionNavigateSource:
599+ pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision)
600+ return nil
601+ case dto.HostDecisionCreateBrowserWindow:
602+ return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, true)
603+ case dto.HostDecisionCreateNativePopup:
604+ return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
605+ case dto.HostDecisionAwaitPopupFeatures:
606+ return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision)
554607 case dto.HostDecisionCreatePane:
555608 // Continue below.
556609 break
@@ -812,6 +865,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin
812865 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
813866 }
814867
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+
815894 func (pm *popupManager) handleLinkMiddleClick(
816895 ctx context.Context,
817896 hooks popupCoordinatorHooks,
@@ -825,13 +904,8 @@ func (pm *popupManager) handleLinkMiddleClick(
825904 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
826905 Msg("middle-click/ctrl+click on link")
827906
828- cfg := pm.currentPopupConfig()
829- if cfg != nil && !cfg.OpenInNewPane {
830- log.Debug().Msg("popups disabled by config, ignoring middle-click")
831- return false
832- }
833- if pm.factory == nil {
834- log.Warn().Msg("no webview factory, cannot handle middle-click")
907+ _, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
908+ if !routingEnabled {
835909 return false
836910 }
837911 if hooks.getWebView == nil {
@@ -844,16 +918,33 @@ func (pm *popupManager) handleLinkMiddleClick(
844918 return false
845919 }
846920
847- decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false)
848- log.Debug().
849- Str("decision", string(decision.Kind)).
850- Str("reason", decision.Reason).
851- Msg("middle-click browsing context host decision")
921+ request := buildLinkBrowsingContextRequest(parentWV.ID(), uri)
922+ request.SourceHost = sourceHost
923+ decision := pm.policy.Decide(request, false)
924+ logBrowsingContextDecision(*log, request, decision)
925+ if decision.Kind == dto.HostDecisionNavigateSource {
926+ return pm.navigateSource(ctx, parentWV, uri)
927+ }
928+ if decision.Kind == dto.HostDecisionCreateBrowserWindow {
929+ return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
930+ }
852931 if decision.Kind != dto.HostDecisionCreatePane {
853932 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
854933 return false
855934 }
856935
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)
857948 newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true)
858949 if err != nil {
859950 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 @@
11 package content
22
33 import (
4- "context"
5- "errors"
6- "sync"
74 "testing"
85
96 "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/mock"
11- "github.com/stretchr/testify/require"
127
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"
168 "github.com/bnema/dumber/internal/domain/entity"
17- domainerrors "github.com/bnema/dumber/internal/domain/errors"
189 )
1910
2011 // ---------------------------------------------------------------------------
@@ -127,756 +118,3 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
127118 }
128119 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
129120 }
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 {
3535 onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
3636 onQuit func()
3737 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
38- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
39- onStateChanged func() // For session snapshots
38+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs
39+ onStateChanged func() // For session snapshots
4040 }
4141
4242 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
132132 }
133133
134134 // 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) {
136136 c.onAttachPopupToTab = fn
137137 }
138138
@@ -540,9 +540,20 @@ func (c *TabCoordinator) CreateWithPane(
540540 // Update tab bar visibility
541541 c.UpdateBarVisibility(ctx, target)
542542
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.
544545 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+ }
546557 }
547558
548559 // Switch to the new tab's workspace view
M internal/ui/floating_pane_test.go +30 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1011 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
@@ -312,6 +313,35 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
312313 assert.False(t, handled)
313314 }
314315
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+
315345 func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {
316346 app, _, session := newFloatingPaneTestApp(t)
317347
M internal/ui/native_popup_window.go +50 -25
@@ -14,25 +14,32 @@ import (
1414 "github.com/bnema/puregotk/v4/gtk"
1515 )
1616
17+type nativePopupShell interface {
18+ DetachContent() *gtk.Widget
19+ Destroy()
20+ Close()
21+ Show()
22+}
23+
1724 type nativePopupWindow struct {
1825 popupID port.WebViewID
1926 parentPaneID entity.PaneID
2027 parentWindowID string
21- popupWindow *window.PopupWindow
28+ popupWindow nativePopupShell
2229 webView port.WebView
2330 closeOnce sync.Once
2431 }
2532
26-type nativePopupDestroyer interface {
27- Destroy()
28-}
33+type nativePopupReleaseMode int
34+
35+const (
36+ nativePopupReleaseDestroy nativePopupReleaseMode = iota
37+ nativePopupReleaseDetach
38+)
2939
3040 var newPopupWindow = window.NewPopup
3141
32-func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {
33- if wv != nil && !wv.IsDestroyed() {
34- wv.Destroy()
35- }
42+func destroyFailedNativePopupSetup(popupShell nativePopupShell) {
3643 if popupShell != nil {
3744 popupShell.Destroy()
3845 }
@@ -67,17 +74,16 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
6774
6875 popupShell, err := newPopupWindow(ctx, a.gtkApp)
6976 if err != nil {
70- destroyFailedNativePopupSetup(nil, input.PopupWebView)
7177 return err
7278 }
7379 if a.contentCoord == nil {
74- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
80+ destroyFailedNativePopupSetup(popupShell)
7581 return fmt.Errorf("content coordinator not available for native popup")
7682 }
7783 widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
7884 gtkWidget, err := prepareNativePopupContentWidget(widget)
7985 if err != nil {
80- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
86+ destroyFailedNativePopupSetup(popupShell)
8187 return err
8288 }
8389 popupShell.SetContent(gtkWidget)
@@ -102,7 +108,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
102108
103109 if popupShell.Window() != nil {
104110 closeRequestCb := func(_ gtk.Window) bool {
105- a.releaseNativePopupWindow(popupID, false, false)
111+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
106112 return false
107113 }
108114 popupShell.Window().ConnectCloseRequest(&closeRequestCb)
@@ -111,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
111117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
112118 aborter.SetNativePopupHostAbort(func() {
113119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
114- a.releaseNativePopupWindow(popupID, true, false)
120+ a.abortNativePopupWindow(context.Background(), popupID, input)
115121 })
116122 })
117123 }
@@ -123,7 +129,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
123129 })
124130 lifecycle.SetOnClose(func() {
125131 a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {
126- a.releaseNativePopupWindow(popupID, true, false)
132+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
127133 })
128134 })
129135 } else {
@@ -132,7 +138,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
132138 if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
133139 oauthWV.AddCloseCallback(func() {
134140 a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {
135- a.releaseNativePopupWindow(popupID, true, false)
141+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
136142 })
137143 })
138144 }
@@ -148,6 +154,22 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
148154 return nil
149155 }
150156
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+
151173 func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {
152174 if a == nil || fn == nil {
153175 return
@@ -181,26 +203,29 @@ func (a *App) showNativePopupWindow(popupID port.WebViewID) {
181203 }
182204 }
183205
184-func (a *App) releaseNativePopupWindow(popupID port.WebViewID, closeWindow, destroyWindow bool) {
206+func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView {
185207 if a == nil || a.nativePopupWindows == nil {
186- return
208+ return nil
187209 }
188210 state := a.nativePopupWindows[popupID]
189211 if state == nil {
190- return
212+ return nil
191213 }
214+ var detached port.WebView
192215 state.closeOnce.Do(func() {
193216 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() {
195224 state.webView.Destroy()
196225 }
197226 if state.popupWindow != nil {
198- switch {
199- case destroyWindow:
200- state.popupWindow.Destroy()
201- case closeWindow:
202- state.popupWindow.Close()
203- }
227+ state.popupWindow.Destroy()
204228 }
205229 })
230+ return detached
206231 }
M internal/ui/native_popup_window_test.go +67 -23
@@ -18,12 +18,16 @@ import (
1818 )
1919
2020 type popupDestroySpy struct {
21- destroyed bool
21+ destroyed int
22+ detached int
23+ closed int
24+ shown int
2225 }
2326
24-func (p *popupDestroySpy) Destroy() {
25- p.destroyed = true
26-}
27+func (p *popupDestroySpy) Destroy() { p.destroyed++ }
28+func (p *popupDestroySpy) DetachContent() *gtk.Widget { p.detached++; return &gtk.Widget{} }
29+func (p *popupDestroySpy) Close() { p.closed++ }
30+func (p *popupDestroySpy) Show() { p.shown++ }
2731
2832 func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) {
2933 widget := layoutmocks.NewMockWidget(t)
@@ -46,7 +50,7 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te
4650 assert.Nil(t, got)
4751 }
4852
49-func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {
53+func TestOpenNativePopupWindowLeavesWebViewOwnedByCallerOnSetupFailure(t *testing.T) {
5054 oldNewPopupWindow := newPopupWindow
5155 t.Cleanup(func() {
5256 newPopupWindow = oldNewPopupWindow
@@ -58,23 +62,15 @@ func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *tes
5862 }
5963
6064 wv := portmocks.NewMockWebView(t)
61- wv.EXPECT().IsDestroyed().Return(false).Once()
62- wv.EXPECT().Destroy().Once()
63-
6465 app := &App{gtkApp: &gtk.Application{}}
6566 err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
6667 require.ErrorIs(t, err, expectedErr)
6768 }
6869
69-func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {
70- wv := portmocks.NewMockWebView(t)
71- wv.EXPECT().IsDestroyed().Return(false).Once()
72- wv.EXPECT().Destroy().Once()
73-
70+func TestDestroyFailedNativePopupSetupDestroysOnlyShell(t *testing.T) {
7471 shell := &popupDestroySpy{}
75- destroyFailedNativePopupSetup(shell, wv)
76-
77- assert.True(t, shell.destroyed)
72+ destroyFailedNativePopupSetup(shell)
73+ assert.Equal(t, 1, shell.destroyed)
7874 }
7975
8076 func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) {
@@ -104,19 +100,67 @@ func TestDispatchNativePopupLifecycleTreatsCompletedAfterTimeoutAsCompleted(t *t
104100 assert.True(t, ran)
105101 }
106102
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) {
108138 wv := portmocks.NewMockWebView(t)
109139 wv.EXPECT().IsDestroyed().Return(false).Once()
110140 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+}
111151
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{}
112157 app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
113- port.WebViewID(1): {
114- popupID: port.WebViewID(1),
115- webView: wv,
116- },
158+ 1: {popupID: 1, webView: wv, popupWindow: shell},
117159 }}
118160
119- app.releaseNativePopupWindow(port.WebViewID(1), false, false)
120- _, ok := app.nativePopupWindows[port.WebViewID(1)]
161+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
162+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
163+ _, ok := app.nativePopupWindows[1]
121164 assert.False(t, ok)
165+ assert.Equal(t, 1, shell.destroyed)
122166 }
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: &gtk.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 := &gtk.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: &gtk.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) {
6161 pw.currentContent = widget
6262 }
6363
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+
6476 func (pw *PopupWindow) Show() {
6577 if pw == nil || pw.window == nil {
6678 return

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close