detached 4cc45bb

Merge pull request #248 from bnema/fix/oauth-popup-regression

Fix OAuth popup flows with browsing-context native windows
commit
4cc45bb4dc29a91ce4cc01d3b630c321c02488e1
author
bnema · · 2026-05-13 18:12 UTC
committer
GitHub · · 2026-05-13 18:12 UTC
merge of
80f6f7b + 499f6a4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
43 files · +3196 -757
M docs/config/index.md +8 -8
@@ -377,17 +377,17 @@ url = "https://github.com"
377 desc = "Open floating pane on GitHub"377 desc = "Open floating pane on GitHub"
378 ```378 ```
379 379
380-### Popup Behavior380+### Browsing Context Behavior
381 381
382 | Key | Type | Default | Valid Values | Description |382 | Key | Type | Default | Valid Values | Description |
383 |-----|------|---------|--------------|-------------|383 |-----|------|---------|--------------|-------------|
384-| `workspace.popups.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Popup opening mode |384+| `workspace.browsing_contexts.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Placement mode for script-opened browsing contexts |
385-| `workspace.popups.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction |385+| `workspace.browsing_contexts.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction when behavior is `split` |
386-| `workspace.popups.open_in_new_pane` | bool | `true` | - | Open popups in workspace |386+| `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | - | Allow new browsing contexts to open in the workspace |
387-| `workspace.popups.follow_pane_context` | bool | `true` | - | Follow parent pane context |387+| `workspace.browsing_contexts.follow_pane_context` | bool | `true` | - | Keep new browsing contexts aligned with the parent pane context |
388-| `workspace.popups.blank_target_behavior` | string | `"pane"` | `pane`, `tab` | `_blank` target handling |388+| `workspace.browsing_contexts.blank_target_behavior` | string | `"stacked"` | `split`, `stacked`, `tabbed` | Placement mode for `_blank` / new-page link contexts |
389-| `workspace.popups.enable_smart_detection` | bool | `true` | - | Use WindowProperties for detection |389+| `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | - | Use window properties to refine browsing-context classification |
390-| `workspace.popups.oauth_auto_close` | bool | `true` | - | Auto-close OAuth popups |390+| `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | - | Auto-close OAuth browsing contexts after success |
391 391
392 ### Workspace Styling392 ### Workspace Styling
393 393
M docs/reference/configuration.md +7 -7
@@ -79,13 +79,13 @@
79 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |79 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
80 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |80 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
81 | `workspace.floating_pane.profiles.<name>.desc` | string | | |81 | `workspace.floating_pane.profiles.<name>.desc` | string | | |
82-| `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |82+| `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
83-| `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |83+| `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
84-| `workspace.popups.open_in_new_pane` | bool | `true` | |84+| `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |
85-| `workspace.popups.follow_pane_context` | bool | `true` | |85+| `workspace.browsing_contexts.follow_pane_context` | bool | `true` | |
86-| `workspace.popups.blank_target_behavior` | string | `pane` | `pane`, `tab` |86+| `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
87-| `workspace.popups.enable_smart_detection` | bool | `true` | |87+| `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
88-| `workspace.popups.oauth_auto_close` | bool | `true` | |88+| `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
89 | `workspace.styling.border_width` | int | `1` | |89 | `workspace.styling.border_width` | int | `1` | |
90 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |90 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
91 | `workspace.styling.mode_border_width` | int | `4` | |91 | `workspace.styling.mode_border_width` | int | `4` | |
A internal/application/dto/browsing_context.go +84 -0
@@ -0,0 +1,84 @@
1+package dto
2+
3+// WindowDisposition describes how the browsing context should be presented
4+// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
5+// dispositions at a normalized product seam.
6+type WindowDisposition string
7+
8+const (
9+ // WindowDispositionCurrentTab replaces the current document in the same browsing context.
10+ 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"
17+)
18+
19+// TriggerKind categorizes what triggered a browsing-context request.
20+type TriggerKind string
21+
22+const (
23+ TriggerUnknown TriggerKind = "unknown"
24+ TriggerLinkNewPage TriggerKind = "link-new-page"
25+ TriggerScriptWindowOpen TriggerKind = "script-window-open"
26+ TriggerNamedTargetNavigation TriggerKind = "named-target-navigation"
27+ TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
28+)
29+
30+// RequestContextDisposition makes request-context inheritance explicit at the
31+// policy seam without leaking infrastructure request-context types upward.
32+type RequestContextDisposition string
33+
34+const (
35+ // RequestContextInheritParent preserves the parent request context/session.
36+ RequestContextInheritParent RequestContextDisposition = "inherit-parent"
37+ // RequestContextIsolate starts from a fresh unrelated request context.
38+ RequestContextIsolate RequestContextDisposition = "isolate"
39+)
40+
41+// NewBrowsingContextRequest is the normalized request captured from engine/UI
42+// signals before policy decides how it should be hosted.
43+type NewBrowsingContextRequest struct {
44+ ParentWebViewID uint64
45+
46+ SourceBrowserID int32
47+ SourceFrameID string
48+ SourceFrameURL string
49+
50+ TargetURI string
51+ TargetFrameName string
52+ TargetDisposition WindowDisposition
53+
54+ IsUserGesture bool
55+ NoJavaScriptAccess bool
56+ WindowFeatures string
57+
58+ TriggerKind TriggerKind
59+ AuthIntent bool
60+ RequiresNativeOpener bool
61+ RequestContextDisposition RequestContextDisposition
62+}
63+
64+// HostDecisionKind categorizes the approved host choices for a new browsing context.
65+type HostDecisionKind string
66+
67+const (
68+ HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
69+ HostDecisionCreatePane HostDecisionKind = "create-pane"
70+ HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
71+ HostDecisionDeny HostDecisionKind = "deny"
72+)
73+
74+// HostDecision represents the output of browsing-context policy.
75+type HostDecision struct {
76+ Kind HostDecisionKind
77+
78+ ReuseContextName string
79+ BrowsingContextName string
80+
81+ RequestContextDisposition RequestContextDisposition
82+ RequiresNativeOpener bool
83+ Reason string
84+}
A internal/application/port/browsing_context.go +27 -0
@@ -0,0 +1,27 @@
1+// Package port defines interfaces (ports) for use cases to depend on.
2+// Concrete browsing-context DTOs live in internal/application/dto.
3+package port
4+
5+import "github.com/bnema/dumber/internal/application/dto"
6+
7+// PaneHostedBrowsingContextCapable is implemented by engines that need an
8+// explicit transition from a related/native-popup shell into a pane-hosted
9+// child browsing context path.
10+type PaneHostedBrowsingContextCapable interface {
11+ PreparePaneHostedBrowsingContext()
12+}
13+
14+// BrowsingContextHostDecisionCapable lets UI coordinators attach the chosen
15+// host decision to an engine webview so lower-level popup handlers can honor
16+// pane-vs-native behavior synchronously.
17+type BrowsingContextHostDecisionCapable interface {
18+ SetBrowsingContextHostDecision(decision dto.HostDecision)
19+ BrowsingContextHostDecision() (dto.HostDecision, bool)
20+}
21+
22+// NativePopupHostAbortCapable lets the engine-level popup seam abort an
23+// already-created native popup shell/window when native arming fails.
24+type NativePopupHostAbortCapable interface {
25+ SetNativePopupHostAbort(fn func())
26+ AbortNativePopupHost()
27+}
M internal/application/port/webview.go +12 -1
@@ -3,7 +3,11 @@
3 // remain independent of specific implementations (WebKit, GTK, etc.).3 // remain independent of specific implementations (WebKit, GTK, etc.).
4 package port4 package port
5 5
6-import "context"6+import (
7+ "context"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+)
7 11
8 // WebViewID uniquely identifies a WebView instance.12 // WebViewID uniquely identifies a WebView instance.
9 type WebViewID uint6413 type WebViewID uint64
@@ -67,6 +71,13 @@ type PopupRequest struct {
67 IsUserGesture bool71 IsUserGesture bool
68 NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access72 NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
69 ParentViewID WebViewID73 ParentViewID WebViewID
74+
75+ SourceBrowserID int32
76+ SourceFrameID string
77+ SourceFrameURL string
78+
79+ TargetDisposition dto.WindowDisposition
80+ WindowFeatures string
70 }81 }
71 82
72 // Texture represents a graphics texture returned by the engine.83 // Texture represents a graphics texture returned by the engine.
M internal/domain/entity/config_types.go +14 -3
@@ -166,8 +166,9 @@ const (
166 OmniboxInitialBehaviorNone OmniboxInitialBehavior = "none"166 OmniboxInitialBehaviorNone OmniboxInitialBehavior = "none"
167 )167 )
168 168
169-// PopupBehaviorConfig controls how popup windows are handled.169+// BrowsingContextConfig controls how browsing contexts (popups, tabs, new windows) are handled.
170-type PopupBehaviorConfig struct {170+// This is the canonical config type; PopupBehaviorConfig is a compatibility alias.
171+type BrowsingContextConfig struct {
171 Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`172 Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
172 173
173 Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`174 Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
@@ -183,6 +184,9 @@ type PopupBehaviorConfig struct {
183 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`184 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
184 }185 }
185 186
187+// Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
188+type PopupBehaviorConfig = BrowsingContextConfig
189+
186 // WorkspaceConfig holds all workspace layout and behavior settings.190 // WorkspaceConfig holds all workspace layout and behavior settings.
187 type WorkspaceConfig struct {191 type WorkspaceConfig struct {
188 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`192 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
@@ -196,7 +200,14 @@ type WorkspaceConfig struct {
196 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line200 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
197 SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line201 SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
198 202
199- Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`203+ // BrowsingContexts is the canonical field for browsing context behavior.
204+ // It replaces the legacy popups configuration.
205+ BrowsingContexts BrowsingContextConfig `mapstructure:"browsing_contexts" yaml:"browsing_contexts" toml:"browsing_contexts" json:"browsing_contexts"` //nolint:lll // struct tags must stay on one line
206+
207+ // Popups is a runtime compatibility alias synced from BrowsingContexts during normalization.
208+ // It is not serialized or deserialized from config files.
209+ Popups BrowsingContextConfig `mapstructure:"-" yaml:"-" toml:"-" json:"-"`
210+
200 Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`211 Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
201 }212 }
202 213
M internal/infrastructure/cef/bridge_script_templates.go +3 -231
@@ -1,7 +1,6 @@
1 package cef1 package cef
2 2
3 import (3 import (
4- "fmt"
5 "strings"4 "strings"
6 5
7 "github.com/bnema/dumber/internal/infrastructure/webutil"6 "github.com/bnema/dumber/internal/infrastructure/webutil"
@@ -12,234 +11,11 @@ import (
12 // bridge nonce placeholder is replaced per navigation.11 // bridge nonce placeholder is replaced per navigation.
13 var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()12 var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()
14 13
15-// rendererBridgeExtensionJS is assembled once from the shared popup proxy14+// rendererBridgeExtensionJS is assembled once for renderer-process bridge helpers.
16-// builder so the renderer-extension and fetch-based popup bridges stay aligned.
17 var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()15 var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()
18 16
19-type popupProxyBridgeDispatch struct {
20- patchFlag string
21- helpers string
22-}
23-
24-const popupProxyBridgeBodyTemplateJS = `
25- if (typeof window === 'undefined') return;
26- if (window.__DUMBER_POPUP_PATCH_FLAG__) return;
27-
28- __DUMBER_POPUP_DISPATCH_HELPERS__
29-
30- function resolvePopupURL(rawURL) {
31- if (rawURL == null || rawURL === '') return '';
32- try {
33- return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
34- } catch (_) {
35- return String(rawURL);
36- }
37- }
38-
39- function hasUserGesture() {
40- try {
41- return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
42- } catch (_) {
43- return false;
44- }
45- }
46-
47- function popupHasNoOpener(features) {
48- if (features == null || features === '') return false;
49- var normalized = String(features).toLowerCase();
50- return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
51- }
52-
53- function shouldDelegateWindowOpen(target) {
54- return target === '_self' || target === '_top' || target === '_parent';
55- }
56-
57- function createPopupProxyID() {
58- var timestamp = Date.now();
59- try {
60- if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') {
61- var bytes = new Uint8Array(4);
62- crypto.getRandomValues(bytes);
63- var nonce = '';
64- for (var i = 0; i < bytes.length; i++) {
65- nonce += ('0' + bytes[i].toString(16)).slice(-2);
66- }
67- return 'popup-' + timestamp + '-' + nonce;
68- }
69- } catch (_) {}
70- return 'popup-' + timestamp + '-' + Math.random().toString(36).slice(2);
71- }
72-
73- function createSyntheticPopupProxy(proxyID, initialURL, features) {
74- var href = initialURL || 'about:blank';
75- var closed = false;
76- var noJavaScriptAccess = popupHasNoOpener(features);
77-
78- function navigate(nextURL) {
79- href = resolvePopupURL(nextURL);
80- dispatchPopupNavigate(proxyID, href);
81- return href;
82- }
83-
84- var locationProxy = {
85- assign: function(nextURL) { return navigate(nextURL); },
86- replace: function(nextURL) { return navigate(nextURL); },
87- toString: function() { return href; }
88- };
89- try {
90- Object.defineProperty(locationProxy, 'href', {
91- configurable: true,
92- enumerable: true,
93- get: function() { return href; },
94- set: function(nextURL) { navigate(nextURL); }
95- });
96- } catch (_) {
97- locationProxy.href = href;
98- }
99-
100- var proxy = {
101- blur: function() { return undefined; },
102- close: function() {
103- if (closed) return;
104- closed = true;
105- dispatchPopupClose(proxyID);
106- },
107- focus: function() { return undefined; },
108- postMessage: function() { return undefined; }
109- };
110- proxy.opener = noJavaScriptAccess ? null : window;
111- proxy.self = proxy;
112- proxy.window = proxy;
113-
114- try {
115- Object.defineProperty(proxy, 'closed', {
116- configurable: true,
117- enumerable: true,
118- get: function() { return closed; },
119- set: function(next) { closed = !!next; }
120- });
121- } catch (_) {
122- proxy.closed = false;
123- }
124-
125- try {
126- Object.defineProperty(proxy, 'location', {
127- configurable: true,
128- enumerable: true,
129- get: function() { return locationProxy; },
130- set: function(nextURL) { navigate(nextURL); }
131- });
132- } catch (_) {
133- proxy.location = locationProxy;
134- }
135-
136- try {
137- Object.defineProperty(proxy, Symbol.toStringTag, {
138- configurable: true,
139- value: 'Window'
140- });
141- } catch (_) {}
142-
143- return proxy;
144- }
145-
146- try {
147- var originalWindowOpen = typeof window.open === 'function' ? window.open : null;
148- if (!originalWindowOpen) return;
149-
150- window.__DUMBER_POPUP_PATCH_FLAG__ = true;
151- window.open = function(url, target, features) {
152- var normalizedTarget = target == null ? '' : String(target);
153- if (shouldDelegateWindowOpen(normalizedTarget)) {
154- return originalWindowOpen.apply(this, arguments);
155- }
156-
157- var proxyID = createPopupProxyID();
158- var resolvedURL = resolvePopupURL(url);
159- var noJavaScriptAccess = popupHasNoOpener(features);
160- var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
161- dispatchPopupOpen({
162- proxy_id: proxyID,
163- url: resolvedURL,
164- frame_name: normalizedTarget,
165- user_gesture: hasUserGesture(),
166- no_javascript_access: noJavaScriptAccess
167- });
168- return popupProxy;
169- };
170- } catch (_) {
171- window.__DUMBER_POPUP_PATCH_FLAG__ = false;
172- }
173-`
174-
175-const popupFetchBridgeHelpersJSTemplate = `
176- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
177-
178- function encodeBody(body) {
179- try {
180- return btoa(unescape(encodeURIComponent(body)));
181- } catch (_) {
182- return '';
183- }
184- }
185-
186- function postBridge(url, payload) {
187- var encoded = encodeBody(JSON.stringify(payload));
188- if (!encoded) return;
189- fetch(url, {
190- method: 'POST',
191- headers: {
192- 'X-Dumber-Body': encoded,
193- 'X-Dumber-Bridge-Nonce': bridgeNonce
194- }
195- }).catch(function() {});
196- }
197-
198- function dispatchPopupOpen(payload) {
199- postBridge('dumb:///api/popup-open', payload);
200- }
201-
202- function dispatchPopupNavigate(proxyID, url) {
203- postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: url });
204- }
205-
206- function dispatchPopupClose(proxyID) {
207- postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
208- }
209-`
210-
211-const rendererPopupDispatchHelpersJS = `
212- function dispatchPopupOpen(payload) {
213- send('popup_open', JSON.stringify(payload));
214- }
215-
216- function dispatchPopupNavigate(proxyID, url) {
217- send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: url }));
218- }
219-
220- function dispatchPopupClose(proxyID) {
221- send('popup_close', JSON.stringify({ proxy_id: proxyID }));
222- }
223-`
224-
225-func buildPopupProxyBridgeBodyJS(dispatch popupProxyBridgeDispatch) string {
226- replacer := strings.NewReplacer(
227- "__DUMBER_POPUP_PATCH_FLAG__", dispatch.patchFlag,
228- "__DUMBER_POPUP_DISPATCH_HELPERS__", dispatch.helpers,
229- )
230- return replacer.Replace(popupProxyBridgeBodyTemplateJS)
231-}
232-
233-func wrapSelfExecutingScript(body string) string {
234- return fmt.Sprintf("(function() {\n%s\n})();", body)
235-}
236-
237 func buildTrustedPageFetchBridgeBaseJS() string {17 func buildTrustedPageFetchBridgeBaseJS() string {
238- popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{18+ return clipboardSelectionFetchBridgeJSTemplate
239- patchFlag: "__dumberPopupBridgePatched",
240- helpers: popupFetchBridgeHelpersJSTemplate,
241- }))
242- return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge
243 }19 }
244 20
245 const bridgeNoncePlaceholder = "__DUMBER_BRIDGE_NONCE__"21 const bridgeNoncePlaceholder = "__DUMBER_BRIDGE_NONCE__"
@@ -252,11 +28,7 @@ func buildTrustedPageFetchBridgeJS(bridgeNonce string) string {
252 }28 }
253 29
254 func buildRendererBridgeExtensionJS() string {30 func buildRendererBridgeExtensionJS() string {
255- popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{31+ return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", "")
256- patchFlag: "__dumberPopupOpenPatched",
257- helpers: rendererPopupDispatchHelpersJS,
258- })
259- return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody)
260 }32 }
261 33
262 func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string {34 func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string {
M internal/infrastructure/cef/content_injector_test.go +7 -12
@@ -63,20 +63,15 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
63 require.Contains(t, script, "test-bridge-nonce")63 require.Contains(t, script, "test-bridge-nonce")
64 }64 }
65 65
66-func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {66+func TestTrustedPageFetchBridgeJS_DoesNotShimWindowOpen(t *testing.T) {
67 script := testTrustedPageFetchBridgeJS()67 script := testTrustedPageFetchBridgeJS()
68 68
69- require.Contains(t, script, "window.__dumberPopupBridgePatched")69+ require.NotContains(t, script, "window.__dumberPopupBridgePatched")
70- require.Contains(t, script, "window.open = function(url, target, features)")70+ require.NotContains(t, script, "window.open = function(url, target, features)")
71- require.Contains(t, script, "dumb:///api/popup-open")71+ require.NotContains(t, script, "dumb:///api/popup-open")
72- require.Contains(t, script, "dumb:///api/popup-navigate")72+ require.NotContains(t, script, "dumb:///api/popup-navigate")
73- require.Contains(t, script, "dumb:///api/popup-close")73+ require.NotContains(t, script, "dumb:///api/popup-close")
74- require.Contains(t, script, "proxy_id")74+ require.NotContains(t, script, "createSyntheticPopupProxy")
75- require.Contains(t, script, "createSyntheticPopupProxy")
76- require.Contains(t, script, "createPopupProxyID")
77- require.Contains(t, script, "crypto.getRandomValues")
78- require.Contains(t, script, "no_javascript_access")
79- require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
80 require.Contains(t, script, "test-bridge-nonce")75 require.Contains(t, script, "test-bridge-nonce")
81 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")76 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
82 }77 }
M internal/infrastructure/cef/renderer_bridge_test.go +7 -9
@@ -97,15 +97,13 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
98 }98 }
99 99
100-func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {100+func TestRendererBridgeExtensionJS_DoesNotShimWindowOpenForSyntheticPopups(t *testing.T) {
101- require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")101+ require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102- require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")102+ require.NotContains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103- require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")103+ require.NotContains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104- require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")104+ require.NotContains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105- require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")105+ require.NotContains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
106- require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")106+ require.NotContains(t, rendererBridgeExtensionJS, "return popupProxy;")
107- require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
108- require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
109 }107 }
110 108
111 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {109 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
M internal/infrastructure/cef/webview.go +65 -25
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/puregotk/v4/glib"18 "github.com/bnema/puregotk/v4/glib"
19 "github.com/bnema/puregotk/v4/gtk"19 "github.com/bnema/puregotk/v4/gtk"
20 20
21+ "github.com/bnema/dumber/internal/application/dto"
21 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
22 "github.com/bnema/dumber/internal/logging"23 "github.com/bnema/dumber/internal/logging"
23 )24 )
@@ -117,9 +118,12 @@ type WebView struct {
117 // crash → redirect → crash loops.118 // crash → redirect → crash loops.
118 crashCount atomic.Int32119 crashCount atomic.Int32
119 120
120- // Callbacks set by use case layer.121+ // Callbacks and browsing-context state set by the UI layer.
121- mu sync.RWMutex122+ mu sync.RWMutex
122- callbacks *port.WebViewCallbacks123+ callbacks *port.WebViewCallbacks
124+ browsingContextDecision dto.HostDecision
125+ hasBrowsingContextDecision bool
126+ nativePopupHostAbort func()
123 127
124 // Synthetic popup proxies created by the renderer bridge's window.open shim.128 // Synthetic popup proxies created by the renderer bridge's window.open shim.
125 syntheticPopupMu sync.Mutex129 syntheticPopupMu sync.Mutex
@@ -656,33 +660,74 @@ func (wv *WebView) isNativePopupCandidate() bool {
656 return wv.nativePopupCandidate660 return wv.nativePopupCandidate
657 }661 }
658 662
659-func popupParentWindowHandle(parent *WebView) uintptr {663+func (wv *WebView) discardNativePopupCandidate() {
660- if parent == nil {664+ if wv == nil {
661- return 0665+ return
662 }666 }
663- parent.mu.RLock()667+ var timer stoppableTimer
664- host := parent.host668+ wv.mu.Lock()
665- parent.mu.RUnlock()669+ timer = wv.nativePopupFallbackTimer
666- if host == nil {670+ wv.nativePopupFallbackTimer = nil
667- return 0671+ wv.nativePopupCandidate = false
672+ wv.nativePopupParent = nil
673+ wv.nativePopupID = 0
674+ wv.nativePopupFallbackStarted = false
675+ wv.popupOpenerBridgeParent = nil
676+ wv.popupOpenerBridgeParentURI = ""
677+ wv.syncPopupOpenerBridgeExtraInfoLocked()
678+ wv.mu.Unlock()
679+ if timer != nil {
680+ timer.Stop()
668 }681 }
669- if handle := host.GetWindowHandle(); handle != 0 {682+}
670- return handle683+
684+func (wv *WebView) PreparePaneHostedBrowsingContext() {
685+ if wv == nil {
686+ return
671 }687 }
672- return host.GetOpenerWindowHandle()688+ wv.discardNativePopupCandidate()
673 }689 }
674 690
675-func (wv *WebView) discardNativePopupCandidate() {691+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
676 if wv == nil {692 if wv == nil {
677 return693 return
678 }694 }
679 wv.mu.Lock()695 wv.mu.Lock()
680- wv.nativePopupCandidate = false696+ wv.browsingContextDecision = decision
681- wv.nativePopupParent = nil697+ wv.hasBrowsingContextDecision = true
682- wv.nativePopupID = 0698+ wv.mu.Unlock()
699+}
700+
701+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
702+ if wv == nil {
703+ return dto.HostDecision{}, false
704+ }
705+ wv.mu.RLock()
706+ defer wv.mu.RUnlock()
707+ return wv.browsingContextDecision, wv.hasBrowsingContextDecision
708+}
709+
710+func (wv *WebView) SetNativePopupHostAbort(fn func()) {
711+ if wv == nil {
712+ return
713+ }
714+ wv.mu.Lock()
715+ wv.nativePopupHostAbort = fn
683 wv.mu.Unlock()716 wv.mu.Unlock()
684 }717 }
685 718
719+func (wv *WebView) AbortNativePopupHost() {
720+ if wv == nil {
721+ return
722+ }
723+ wv.mu.RLock()
724+ fn := wv.nativePopupHostAbort
725+ wv.mu.RUnlock()
726+ if fn != nil {
727+ fn()
728+ }
729+}
730+
686 func (wv *WebView) awaitsNativePopupAttachment() bool {731 func (wv *WebView) awaitsNativePopupAttachment() bool {
687 if wv == nil {732 if wv == nil {
688 return false733 return false
@@ -836,10 +881,6 @@ func (wv *WebView) prepareNativePopup(
836 if !ok {881 if !ok {
837 return false882 return false
838 }883 }
839- parentWindowHandle := popupParentWindowHandle(prep.parent)
840- if parentWindowHandle == 0 {
841- return false
842- }
843 client, ok := wv.activateNativePopup(popupID, targetURL)884 client, ok := wv.activateNativePopup(popupID, targetURL)
844 if !ok {885 if !ok {
845 return false886 return false
@@ -848,7 +889,7 @@ func (wv *WebView) prepareNativePopup(
848 prep.parent.trackPendingNativePopup(popupID, wv)889 prep.parent.trackPendingNativePopup(popupID, wv)
849 }890 }
850 891
851- configureNativePopupWindow(windowInfo, settings, parentWindowHandle, prep.frameRate, prep.backgroundColor)892+ configureNativePopupWindow(windowInfo, settings, prep.frameRate, prep.backgroundColor)
852 clientSlot.Set(client)893 clientSlot.Set(client)
853 return true894 return true
854 }895 }
@@ -900,11 +941,10 @@ func (wv *WebView) canPrepareNativePopupLocked() bool {
900 func configureNativePopupWindow(941 func configureNativePopupWindow(
901 windowInfo *purecef.WindowInfo,942 windowInfo *purecef.WindowInfo,
902 settings *purecef.BrowserSettings,943 settings *purecef.BrowserSettings,
903- parentWindowHandle uintptr,
904 frameRate int32,944 frameRate int32,
905 backgroundColor uint32,945 backgroundColor uint32,
906 ) {946 ) {
907- purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false)947+ cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})
908 if externalBeginFrameEnabled() {948 if externalBeginFrameEnabled() {
909 windowInfo.ExternalBeginFrameEnabled = 1949 windowInfo.ExternalBeginFrameEnabled = 1
910 }950 }
M internal/infrastructure/cef/webview_handlers.go +80 -11
@@ -8,6 +8,7 @@ import (
8 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
9 cef2gtk "github.com/bnema/purego-cef2gtk"9 cef2gtk "github.com/bnema/purego-cef2gtk"
10 10
11+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
12 downloadutil "github.com/bnema/dumber/internal/domain/download"13 downloadutil "github.com/bnema/dumber/internal/domain/download"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
@@ -419,6 +420,24 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
419 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {420 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
420 }421 }
421 422
423+func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
424+ switch disposition {
425+ case purecef.WindowOpenDispositionWodCurrentTab:
426+ return dto.WindowDispositionCurrentTab
427+ case purecef.WindowOpenDispositionWodNewPopup:
428+ return dto.WindowDispositionNewPopup
429+ case purecef.WindowOpenDispositionWodNewWindow:
430+ return dto.WindowDispositionNewWindow
431+ case purecef.WindowOpenDispositionWodNewForegroundTab,
432+ purecef.WindowOpenDispositionWodNewBackgroundTab,
433+ purecef.WindowOpenDispositionWodSingletonTab,
434+ purecef.WindowOpenDispositionWodSwitchToTab:
435+ return dto.WindowDispositionNewTab
436+ default:
437+ return ""
438+ }
439+}
440+
422 // ===========================================================================441 // ===========================================================================
423 // LifeSpanHandler (6 methods)442 // LifeSpanHandler (6 methods)
424 // ===========================================================================443 // ===========================================================================
@@ -429,8 +448,8 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
429 // preserved. Otherwise we keep blocking and let the coordinator's fallback448 // preserved. Otherwise we keep blocking and let the coordinator's fallback
430 // pane handle the navigation.449 // pane handle the navigation.
431 func (h *handlerSet) OnBeforePopup(450 func (h *handlerSet) OnBeforePopup(
432- _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,451+ browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
433- _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,452+ targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
434 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,453 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
435 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,454 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
436 ) bool {455 ) bool {
@@ -450,6 +469,17 @@ func (h *handlerSet) OnBeforePopup(
450 requestNoJavaScriptAccess = *noJavaScriptAccess469 requestNoJavaScriptAccess = *noJavaScriptAccess
451 }470 }
452 var popup port.WebView471 var popup port.WebView
472+ sourceBrowserID := int32(0)
473+ if browser != nil {
474+ sourceBrowserID = browser.GetIdentifier()
475+ }
476+ sourceFrameID := ""
477+ sourceFrameURL := ""
478+ if frame != nil {
479+ sourceFrameID = frame.GetIdentifier()
480+ sourceFrameURL = frame.GetURL()
481+ }
482+
453 h.wv.runOnGTKSync(func() {483 h.wv.runOnGTKSync(func() {
454 popup = cb.OnCreate(port.PopupRequest{484 popup = cb.OnCreate(port.PopupRequest{
455 TargetURI: targetURL,485 TargetURI: targetURL,
@@ -457,6 +487,10 @@ func (h *handlerSet) OnBeforePopup(
457 IsUserGesture: userGesture != 0,487 IsUserGesture: userGesture != 0,
458 NoJavaScriptAccess: requestNoJavaScriptAccess,488 NoJavaScriptAccess: requestNoJavaScriptAccess,
459 ParentViewID: h.wv.id,489 ParentViewID: h.wv.id,
490+ SourceBrowserID: sourceBrowserID,
491+ SourceFrameID: sourceFrameID,
492+ SourceFrameURL: sourceFrameURL,
493+ TargetDisposition: mapCEFWindowDisposition(targetDisposition),
460 })494 })
461 })495 })
462 496
@@ -464,17 +498,52 @@ func (h *handlerSet) OnBeforePopup(
464 if !ok || cefPopup == nil {498 if !ok || cefPopup == nil {
465 return true499 return true
466 }500 }
467- cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)501+
468- if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {502+ decision := dto.HostDecision{}
469- return false503+ if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
504+ if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
505+ decision = got
506+ }
470 }507 }
471 508
472- cefPopup.discardNativePopupCandidate()509+ cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
473- logging.FromContext(h.currentContext()).Debug().510+ switch decision.Kind {
474- Int32("popup_id", popupID).511+ case dto.HostDecisionCreateNativeWin:
475- Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).512+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
476- Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")513+ logging.FromContext(h.currentContext()).Debug().
477- return true514+ Int32("popup_id", popupID).
515+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
516+ Msg("cef: native popup armed")
517+ return false
518+ }
519+ if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok {
520+ aborter.AbortNativePopupHost()
521+ }
522+ cefPopup.discardNativePopupCandidate()
523+ logging.FromContext(h.currentContext()).Warn().
524+ Int32("popup_id", popupID).
525+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
526+ Msg("cef: native popup arming failed, denying without fallback")
527+ return true
528+ case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane:
529+ logging.FromContext(h.currentContext()).Debug().
530+ Int32("popup_id", popupID).
531+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
532+ Str("decision", string(decision.Kind)).
533+ Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation")
534+ return true
535+ default:
536+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
537+ return false
538+ }
539+
540+ cefPopup.discardNativePopupCandidate()
541+ logging.FromContext(h.currentContext()).Debug().
542+ Int32("popup_id", popupID).
543+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
544+ Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
545+ return true
546+ }
478 }547 }
479 548
480 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {549 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
M internal/infrastructure/cef/webview_handlers_test.go +87 -0
@@ -98,6 +98,39 @@ type stubStoppableTimer struct{}
98 98
99 func (stubStoppableTimer) Stop() bool { return true }99 func (stubStoppableTimer) Stop() bool { return true }
100 100
101+type spyStoppableTimer struct{ stopped bool }
102+
103+func (t *spyStoppableTimer) Stop() bool {
104+ t.stopped = true
105+ return true
106+}
107+
108+func TestPreparePaneHostedBrowsingContext_ClearsNativePopupFallbackState(t *testing.T) {
109+ parent := &WebView{ctx: context.Background()}
110+ timer := &spyStoppableTimer{}
111+ wv := &WebView{
112+ nativePopupCandidate: true,
113+ nativePopupParent: parent,
114+ nativePopupID: 77,
115+ nativePopupFallbackStarted: true,
116+ nativePopupFallbackTimer: timer,
117+ pendingCreate: &pendingBrowserCreate{},
118+ popupOpenerBridgeParent: parent,
119+ popupOpenerBridgeParentURI: "https://example.com/login",
120+ }
121+
122+ wv.PreparePaneHostedBrowsingContext()
123+
124+ require.False(t, wv.nativePopupCandidate)
125+ require.Nil(t, wv.nativePopupParent)
126+ require.Zero(t, wv.nativePopupID)
127+ require.False(t, wv.nativePopupFallbackStarted)
128+ require.Nil(t, wv.nativePopupFallbackTimer)
129+ require.True(t, timer.stopped)
130+ require.Nil(t, wv.popupOpenerBridgeParent)
131+ require.Empty(t, wv.popupOpenerBridgeParentURI)
132+}
133+
101 func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {134 func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
102 wv := &WebView{135 wv := &WebView{
103 nativePopupCandidate: true,136 nativePopupCandidate: true,
@@ -247,6 +280,60 @@ func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T
247 require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())280 require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
248 }281 }
249 282
283+func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {
284+ parentWV := &WebView{ctx: context.Background(), id: 41}
285+ popupWV := &WebView{ctx: context.Background(), id: 42}
286+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
287+ parentWV.SetCallbacks(&port.WebViewCallbacks{
288+ OnCreate: func(req port.PopupRequest) port.WebView {
289+ popupWV.PreparePaneHostedBrowsingContext()
290+ popupWV.PrimePopupNavigation(req.TargetURI)
291+ return popupWV
292+ },
293+ })
294+
295+ h := &handlerSet{wv: parentWV}
296+ blocked := h.OnBeforePopup(nil, nil, 81, "https://example.com/pane", "_blank", 0, 1, nil, nil, nil, nil, nil, nil)
297+
298+ require.True(t, blocked)
299+ require.Equal(t, "https://example.com/pane", popupWV.pendingNavigationURI())
300+}
301+
302+func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) {
303+ parentWV := &WebView{ctx: context.Background(), id: 51}
304+ popupWV := &WebView{ctx: context.Background(), id: 52}
305+ popupWV.markNativePopupCandidate(parentWV)
306+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
307+ aborted := false
308+ popupWV.SetNativePopupHostAbort(func() { aborted = true })
309+ parentWV.SetCallbacks(&port.WebViewCallbacks{
310+ OnCreate: func(req port.PopupRequest) port.WebView {
311+ popupWV.PrimePopupNavigation(req.TargetURI)
312+ return popupWV
313+ },
314+ })
315+
316+ h := &handlerSet{wv: parentWV}
317+ blocked := h.OnBeforePopup(nil, nil, 82, "https://accounts.google.com/o/oauth2/v2/auth", "oauth", 0, 1, nil, nil, nil, nil, nil, nil)
318+
319+ require.True(t, blocked)
320+ require.True(t, aborted)
321+}
322+
323+func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) {
324+ windowInfo := purecef.NewWindowInfo()
325+ settings := purecef.NewBrowserSettings()
326+
327+ configureNativePopupWindow(&windowInfo, &settings, 72, 0xFF112233)
328+
329+ require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)
330+ require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
331+ require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
332+ require.Equal(t, int32(72), settings.WindowlessFrameRate)
333+ require.Equal(t, int32(1), settings.LocalStorage)
334+ require.Equal(t, uint32(0xFF112233), settings.BackgroundColor)
335+}
336+
250 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {337 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
251 wv := &WebView{ctx: context.Background()}338 wv := &WebView{ctx: context.Background()}
252 var gotEvents []port.LoadEvent339 var gotEvents []port.LoadEvent
A internal/infrastructure/config/browsing_contexts_test.go +442 -0
@@ -0,0 +1,442 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/spf13/viper"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// --- Legacy popups → browsing_contexts transform tests ---
15+
16+func TestTransformLegacyPopupsToBrowsingContexts_BasicMapping(t *testing.T) {
17+ transformer := NewLegacyConfigTransformer()
18+
19+ rawConfig := map[string]any{
20+ "workspace": map[string]any{
21+ "popups": map[string]any{
22+ "behavior": "tabbed",
23+ "oauth_auto_close": false,
24+ },
25+ },
26+ }
27+
28+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
29+
30+ ws := rawConfig["workspace"].(map[string]any)
31+ // Old key should be deleted.
32+ _, hasOld := ws["popups"]
33+ assert.False(t, hasOld, "popups key should be removed after transform")
34+
35+ // New key should contain the old values.
36+ newCfg, hasNew := ws["browsing_contexts"].(map[string]any)
37+ require.True(t, hasNew, "browsing_contexts key should exist")
38+ assert.Equal(t, "tabbed", newCfg["behavior"])
39+ assert.Equal(t, false, newCfg["oauth_auto_close"])
40+}
41+
42+func TestTransformLegacyPopupsToBrowsingContexts_NewKeyAlreadyPresent(t *testing.T) {
43+ transformer := NewLegacyConfigTransformer()
44+
45+ rawConfig := map[string]any{
46+ "workspace": map[string]any{
47+ "popups": map[string]any{
48+ "behavior": "tabbed",
49+ },
50+ "browsing_contexts": map[string]any{
51+ "behavior": "split",
52+ },
53+ },
54+ }
55+
56+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
57+
58+ ws := rawConfig["workspace"].(map[string]any)
59+ // Old key should still exist (new key was already present, skip transform).
60+ oldCfg, hasOld := ws["popups"].(map[string]any)
61+ assert.True(t, hasOld, "popups key should remain when browsing_contexts already exists")
62+ assert.Equal(t, "tabbed", oldCfg["behavior"])
63+
64+ // New key should NOT be overwritten.
65+ newCfg, hasNew := ws["browsing_contexts"].(map[string]any)
66+ require.True(t, hasNew, "browsing_contexts key should still exist")
67+ assert.Equal(t, "split", newCfg["behavior"], "browsing_contexts should not be overwritten by legacy transform")
68+}
69+
70+func TestTransformLegacyPopupsToBrowsingContexts_NoWorkspace(t *testing.T) {
71+ transformer := NewLegacyConfigTransformer()
72+ rawConfig := map[string]any{"other": "value"}
73+ // Should not panic
74+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
75+ assert.NotNil(t, rawConfig)
76+}
77+
78+func TestTransformLegacyPopupsToBrowsingContexts_NoPopupsKey(t *testing.T) {
79+ transformer := NewLegacyConfigTransformer()
80+ rawConfig := map[string]any{
81+ "workspace": map[string]any{
82+ "new_pane_url": "about:blank",
83+ },
84+ }
85+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
86+ ws := rawConfig["workspace"].(map[string]any)
87+ assert.Equal(t, "about:blank", ws["new_pane_url"])
88+ _, hasBC := ws["browsing_contexts"]
89+ assert.False(t, hasBC, "browsing_contexts should not be created if popups absent")
90+}
91+
92+// --- Default config writing emits browsing_contexts instead of popups ---
93+
94+func TestDefaultConfig_TOMLHasBrowsingContextsNotPopups(t *testing.T) {
95+ cfg := DefaultConfig()
96+
97+ content, err := encodeConfigToTOML(cfg)
98+ require.NoError(t, err)
99+
100+ // Should contain [workspace.browsing_contexts] section.
101+ assert.Contains(t, content, "[workspace.browsing_contexts]",
102+ "TOML output should contain browsing_contexts section")
103+
104+ // Should NOT contain [workspace.popups] section.
105+ assert.NotContains(t, content, "[workspace.popups]",
106+ "TOML output should NOT contain deprecated popups section")
107+
108+ // The section should have the expected keys.
109+ assert.Contains(t, content, "behavior = ")
110+ assert.Contains(t, content, "oauth_auto_close = ")
111+}
112+
113+// --- Loader defaults expose browsing_contexts keys, NOT popups ---
114+
115+func TestSetWorkspaceDefaults_ExposesBrowsingContextsNotPopups(t *testing.T) {
116+ mgr := &Manager{viper: viper.New()}
117+ mgr.setDefaults()
118+
119+ // browsing_contexts defaults should be registered.
120+ assert.Equal(t, "split", mgr.viper.GetString("workspace.browsing_contexts.behavior"))
121+ assert.Equal(t, "right", mgr.viper.GetString("workspace.browsing_contexts.placement"))
122+ assert.Equal(t, "stacked", mgr.viper.GetString("workspace.browsing_contexts.blank_target_behavior"))
123+ assert.True(t, mgr.viper.GetBool("workspace.browsing_contexts.open_in_new_pane"))
124+ assert.True(t, mgr.viper.GetBool("workspace.browsing_contexts.follow_pane_context"))
125+ assert.True(t, mgr.viper.GetBool("workspace.browsing_contexts.enable_smart_detection"))
126+ assert.True(t, mgr.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
127+
128+ // popups defaults should NOT be registered.
129+ assert.Empty(t, mgr.viper.GetString("workspace.popups.behavior"))
130+ assert.Empty(t, mgr.viper.GetString("workspace.popups.placement"))
131+ assert.Empty(t, mgr.viper.GetString("workspace.popups.blank_target_behavior"))
132+ assert.False(t, mgr.viper.GetBool("workspace.popups.open_in_new_pane"))
133+ assert.False(t, mgr.viper.GetBool("workspace.popups.follow_pane_context"))
134+ assert.False(t, mgr.viper.GetBool("workspace.popups.enable_smart_detection"))
135+ assert.False(t, mgr.viper.GetBool("workspace.popups.oauth_auto_close"))
136+}
137+
138+func TestBindBrowsingContextEnvAliases_AcceptsLegacyPopupEnvNames(t *testing.T) {
139+ t.Setenv("DUMBER_WORKSPACE_POPUPS_BEHAVIOR", "tabbed")
140+
141+ v := viper.New()
142+ v.SetEnvPrefix("DUMBER")
143+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
144+ v.AutomaticEnv()
145+ v.SetDefault("workspace.browsing_contexts.behavior", "split")
146+ require.NoError(t, bindBrowsingContextEnvAliases(v))
147+
148+ assert.Equal(t, "tabbed", v.GetString("workspace.browsing_contexts.behavior"))
149+}
150+
151+// --- Validation error paths mention workspace.browsing_contexts, not workspace.popups ---
152+
153+func TestValidatePopups_ErrorMessagesUseBrowsingContexts(t *testing.T) {
154+ tests := []struct {
155+ name string
156+ mutate func(*Config)
157+ wantText string
158+ }{
159+ {
160+ name: "invalid behavior",
161+ mutate: func(cfg *Config) {
162+ cfg.Workspace.BrowsingContexts.Behavior = PopupBehavior("bad")
163+ },
164+ wantText: "workspace.browsing_contexts.behavior",
165+ },
166+ {
167+ name: "invalid placement",
168+ mutate: func(cfg *Config) {
169+ cfg.Workspace.BrowsingContexts.Behavior = PopupBehaviorSplit
170+ cfg.Workspace.BrowsingContexts.Placement = "center"
171+ },
172+ wantText: "workspace.browsing_contexts.placement",
173+ },
174+ {
175+ name: "invalid blank_target_behavior",
176+ mutate: func(cfg *Config) {
177+ cfg.Workspace.BrowsingContexts.BlankTargetBehavior = "popup"
178+ },
179+ wantText: "workspace.browsing_contexts.blank_target_behavior",
180+ },
181+ }
182+
183+ for _, tt := range tests {
184+ t.Run(tt.name, func(t *testing.T) {
185+ cfg := DefaultConfig()
186+ // Sync Popups alias so both fields match (validation reads BrowsingContexts).
187+ cfg.Workspace.Popups = cfg.Workspace.BrowsingContexts
188+ tt.mutate(cfg)
189+ cfg.Workspace.Popups = cfg.Workspace.BrowsingContexts
190+
191+ err := validateConfig(cfg)
192+ require.Error(t, err)
193+ assert.Contains(t, err.Error(), tt.wantText,
194+ "validation error should reference browsing_contexts, not popups")
195+
196+ // Ensure it does NOT reference the old popups nomenclature.
197+ assert.NotContains(t, err.Error(), "workspace.popups.",
198+ "validation error should NOT reference deprecated workspace.popups")
199+ })
200+ }
201+}
202+
203+func TestValidatePopups_CompatibilityAliasMirrorsBrowsingContexts(t *testing.T) {
204+ // When BrowsingContexts is valid but Popups alias is stale,
205+ // validation should still pass because it reads BrowsingContexts.
206+ cfg := DefaultConfig()
207+ // Deliberately make Popups stale.
208+ cfg.Workspace.Popups.Behavior = PopupBehavior("invalid")
209+ cfg.Workspace.Popups.OAuthAutoClose = !cfg.Workspace.BrowsingContexts.OAuthAutoClose
210+
211+ err := validateConfig(cfg)
212+ require.NoError(t, err, "validation should read BrowsingContexts, not the stale Popups alias")
213+}
214+
215+// --- Runtime alias sync (BrowsingContexts → Popups) ---
216+
217+func TestNormalizeBrowsingContexts_SyncsPopupsAlias(t *testing.T) {
218+ cfg := DefaultConfig()
219+
220+ // Make BrowsingContexts and Popups diverge.
221+ cfg.Workspace.BrowsingContexts.Behavior = PopupBehaviorTabbed
222+ cfg.Workspace.Popups.Behavior = PopupBehaviorSplit
223+
224+ normalizeBrowsingContexts(cfg)
225+
226+ assert.Equal(t, PopupBehaviorTabbed, cfg.Workspace.Popups.Behavior,
227+ "normalizeConfig should sync Popups ← BrowsingContexts")
228+ assert.Equal(t, cfg.Workspace.BrowsingContexts.Placement, cfg.Workspace.Popups.Placement)
229+ assert.Equal(t, cfg.Workspace.BrowsingContexts.OAuthAutoClose, cfg.Workspace.Popups.OAuthAutoClose)
230+ assert.Equal(t, cfg.Workspace.BrowsingContexts.OpenInNewPane, cfg.Workspace.Popups.OpenInNewPane)
231+}
232+
233+func TestDefaultConfig_PopupsMirrorsBrowsingContexts(t *testing.T) {
234+ cfg := DefaultConfig()
235+
236+ // After DefaultConfig(), both fields should be identical.
237+ assert.Equal(t, cfg.Workspace.BrowsingContexts.Behavior, cfg.Workspace.Popups.Behavior)
238+ assert.Equal(t, cfg.Workspace.BrowsingContexts.Placement, cfg.Workspace.Popups.Placement)
239+ assert.Equal(t, cfg.Workspace.BrowsingContexts.BlankTargetBehavior, cfg.Workspace.Popups.BlankTargetBehavior)
240+ assert.Equal(t, cfg.Workspace.BrowsingContexts.OpenInNewPane, cfg.Workspace.Popups.OpenInNewPane)
241+ assert.Equal(t, cfg.Workspace.BrowsingContexts.FollowPaneContext, cfg.Workspace.Popups.FollowPaneContext)
242+ assert.Equal(t, cfg.Workspace.BrowsingContexts.EnableSmartDetection, cfg.Workspace.Popups.EnableSmartDetection)
243+ assert.Equal(t, cfg.Workspace.BrowsingContexts.OAuthAutoClose, cfg.Workspace.Popups.OAuthAutoClose)
244+}
245+
246+func TestSchemaProvider_UsesBrowsingContextsKeys(t *testing.T) {
247+ provider := NewSchemaProvider()
248+ schema := provider.GetSchema()
249+
250+ foundBrowsingContexts := false
251+ foundPopups := false
252+
253+ for _, key := range schema {
254+ if key.Key == "workspace.browsing_contexts.behavior" {
255+ foundBrowsingContexts = true
256+ }
257+ if key.Key == "workspace.popups.behavior" {
258+ foundPopups = true
259+ }
260+ }
261+
262+ assert.True(t, foundBrowsingContexts, "schema should include workspace.browsing_contexts.* keys")
263+ assert.False(t, foundPopups, "schema should NOT include workspace.popups.* keys")
264+}
265+
266+// --- End-to-end: legacy config round-trips through viper ---
267+
268+func TestLegacyPopupsFullRoundTrip(t *testing.T) {
269+ // Simulate reading an old config file with [workspace.popups].
270+ m := &Manager{viper: viper.New()}
271+ m.viper.Set("workspace.popups.behavior", "stacked")
272+ m.viper.Set("workspace.popups.placement", "left")
273+ m.viper.Set("workspace.popups.oauth_auto_close", false)
274+
275+ // Transform should map popups → browsing_contexts.
276+ m.transformLegacyConfig()
277+
278+ // popups key should be gone from viper after transform.
279+ assert.False(t, m.viper.IsSet("workspace.popups.behavior"),
280+ "workspace.popups.behavior should not exist after transform")
281+
282+ // browsing_contexts should now hold the values.
283+ assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
284+ assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
285+ assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
286+
287+ // Unmarshal should succeed.
288+ cfg := &Config{}
289+ err := m.viper.Unmarshal(cfg)
290+ require.NoError(t, err)
291+
292+ // BrowsingContexts should have the legacy values.
293+ assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
294+ assert.Equal(t, "left", cfg.Workspace.BrowsingContexts.Placement)
295+ assert.False(t, cfg.Workspace.BrowsingContexts.OAuthAutoClose)
296+
297+ // Popups should be empty (not unmarshaled, will be synced in normalizeConfig).
298+ assert.Equal(t, PopupBehavior(""), cfg.Workspace.Popups.Behavior)
299+
300+ // After normalization, Popups should mirror BrowsingContexts.
301+ normalizeBrowsingContexts(cfg)
302+ assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.Popups.Behavior)
303+ assert.Equal(t, "left", cfg.Workspace.Popups.Placement)
304+ assert.False(t, cfg.Workspace.Popups.OAuthAutoClose)
305+}
306+
307+func TestLegacyPopupsFileOverridesBrowsingContextDefaults(t *testing.T) {
308+ configFile := filepath.Join(t.TempDir(), "config.toml")
309+ content := `
310+[workspace.popups]
311+behavior = "stacked"
312+placement = "left"
313+oauth_auto_close = false
314+`
315+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
316+
317+ m := &Manager{viper: viper.New()}
318+ m.viper.SetConfigFile(configFile)
319+ m.setDefaults()
320+ require.NoError(t, m.viper.ReadInConfig())
321+
322+ m.transformLegacyConfig()
323+
324+ assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
325+ assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
326+ assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
327+
328+ cfg := &Config{}
329+ require.NoError(t, m.viper.Unmarshal(cfg))
330+ normalizeConfig(cfg)
331+
332+ assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
333+ assert.Equal(t, "left", cfg.Workspace.BrowsingContexts.Placement)
334+ assert.False(t, cfg.Workspace.BrowsingContexts.OAuthAutoClose)
335+ assert.Equal(t, cfg.Workspace.BrowsingContexts, cfg.Workspace.Popups)
336+}
337+
338+func TestLegacyPopupsFilePreservesCanonicalBrowsingContextsEnvOverride(t *testing.T) {
339+ configFile := filepath.Join(t.TempDir(), "config.toml")
340+ content := `
341+[workspace.popups]
342+behavior = "stacked"
343+placement = "left"
344+`
345+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
346+ t.Setenv("DUMBER_WORKSPACE_BROWSING_CONTEXTS_BEHAVIOR", "tabbed")
347+
348+ m := &Manager{viper: viper.New()}
349+ m.viper.SetConfigType("toml")
350+ m.viper.SetConfigFile(configFile)
351+ m.viper.SetEnvPrefix("DUMBER")
352+ m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
353+ m.viper.AutomaticEnv()
354+ m.setDefaults()
355+ require.NoError(t, m.viper.ReadInConfig())
356+
357+ m.transformLegacyConfig()
358+
359+ assert.Equal(t, "tabbed", m.viper.GetString("workspace.browsing_contexts.behavior"))
360+ assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
361+}
362+
363+func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {
364+ configFile := filepath.Join(t.TempDir(), "config.toml")
365+ writeConfig := func(content string) {
366+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
367+ }
368+
369+ m := &Manager{viper: viper.New()}
370+ m.viper.SetConfigType("toml")
371+ m.viper.SetConfigFile(configFile)
372+ m.viper.SetEnvPrefix("DUMBER")
373+ m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
374+ m.viper.AutomaticEnv()
375+ m.setDefaults()
376+
377+ writeConfig(`
378+[workspace.popups]
379+behavior = "stacked"
380+placement = "left"
381+`)
382+ require.NoError(t, m.reload())
383+ require.Equal(t, PopupBehaviorStacked, m.config.Workspace.BrowsingContexts.Behavior)
384+ require.Equal(t, "left", m.config.Workspace.BrowsingContexts.Placement)
385+
386+ writeConfig(`
387+[workspace.popups]
388+behavior = "tabbed"
389+placement = "bottom"
390+`)
391+ require.NoError(t, m.reload())
392+ assert.Equal(t, PopupBehaviorTabbed, m.config.Workspace.BrowsingContexts.Behavior)
393+ assert.Equal(t, "bottom", m.config.Workspace.BrowsingContexts.Placement)
394+ assert.Equal(t, m.config.Workspace.BrowsingContexts, m.config.Workspace.Popups)
395+}
396+
397+func TestReload_MixedLegacyAndCanonicalBrowsingContexts_BackfillsMissingCanonicalFields(t *testing.T) {
398+ configFile := filepath.Join(t.TempDir(), "config.toml")
399+ require.NoError(t, os.WriteFile(configFile, []byte(`
400+[workspace.popups]
401+placement = "left"
402+oauth_auto_close = false
403+
404+[workspace.browsing_contexts]
405+behavior = "tabbed"
406+`), 0o644))
407+
408+ m := &Manager{viper: viper.New()}
409+ m.viper.SetConfigType("toml")
410+ m.viper.SetConfigFile(configFile)
411+ m.viper.SetEnvPrefix("DUMBER")
412+ m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
413+ m.viper.AutomaticEnv()
414+ m.setDefaults()
415+
416+ require.NoError(t, m.reload())
417+ assert.Equal(t, PopupBehaviorTabbed, m.config.Workspace.BrowsingContexts.Behavior)
418+ assert.Equal(t, "left", m.config.Workspace.BrowsingContexts.Placement)
419+ assert.False(t, m.config.Workspace.BrowsingContexts.OAuthAutoClose)
420+ assert.Equal(t, m.config.Workspace.BrowsingContexts, m.config.Workspace.Popups)
421+}
422+
423+func TestSave_WhenWatching_SyncsPopupsAliasBeforeSkipReload(t *testing.T) {
424+ configFile := filepath.Join(t.TempDir(), "config.toml")
425+ m := &Manager{viper: viper.New(), watching: true}
426+ m.viper.SetConfigType("toml")
427+ m.viper.SetConfigFile(configFile)
428+ m.setDefaults()
429+ m.config = DefaultConfig()
430+
431+ cfg := DefaultConfig()
432+ cfg.Workspace.BrowsingContexts.Behavior = PopupBehaviorTabbed
433+ cfg.Workspace.BrowsingContexts.Placement = "bottom"
434+ cfg.Workspace.Popups.Behavior = PopupBehaviorSplit
435+ cfg.Workspace.Popups.Placement = "left"
436+
437+ require.NoError(t, m.Save(cfg))
438+ assert.True(t, m.skipNextReload)
439+ assert.Equal(t, cfg.Workspace.BrowsingContexts.Behavior, m.config.Workspace.BrowsingContexts.Behavior)
440+ assert.Equal(t, cfg.Workspace.BrowsingContexts.Placement, m.config.Workspace.BrowsingContexts.Placement)
441+ assert.Equal(t, m.config.Workspace.BrowsingContexts, m.config.Workspace.Popups)
442+}
M internal/infrastructure/config/defaults.go +16 -9
@@ -76,6 +76,18 @@ const (
76 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting76 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
77 )77 )
78 78
79+func defaultBrowsingContextConfig() BrowsingContextConfig {
80+ return BrowsingContextConfig{
81+ Behavior: PopupBehaviorSplit,
82+ Placement: defaultPopupPlacement,
83+ OpenInNewPane: true,
84+ FollowPaneContext: true,
85+ BlankTargetBehavior: "stacked",
86+ EnableSmartDetection: true,
87+ OAuthAutoClose: true,
88+ }
89+}
90+
79 // getDefaultLogDir returns the default log directory, falls back to empty string on error91 // getDefaultLogDir returns the default log directory, falls back to empty string on error
80 func getDefaultLogDir() string {92 func getDefaultLogDir() string {
81 logDir, err := GetLogDir()93 logDir, err := GetLogDir()
@@ -87,6 +99,8 @@ func getDefaultLogDir() string {
87 99
88 // DefaultConfig returns the default configuration values for dumber.100 // DefaultConfig returns the default configuration values for dumber.
89 func DefaultConfig() *Config {101 func DefaultConfig() *Config {
102+ browsingContextDefaults := defaultBrowsingContextConfig()
103+
90 return &Config{104 return &Config{
91 Database: DatabaseConfig{105 Database: DatabaseConfig{
92 // Path is set dynamically in config.Load()106 // Path is set dynamically in config.Load()
@@ -254,15 +268,8 @@ func DefaultConfig() *Config {
254 },268 },
255 TabBarPosition: defaultTabBarPosition,269 TabBarPosition: defaultTabBarPosition,
256 HideTabBarWhenSingleTab: true,270 HideTabBarWhenSingleTab: true,
257- Popups: PopupBehaviorConfig{271+ BrowsingContexts: browsingContextDefaults,
258- Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes272+ Popups: browsingContextDefaults,
259- Placement: defaultPopupPlacement,
260- OpenInNewPane: true,
261- FollowPaneContext: true,
262- BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
263- EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
264- OAuthAutoClose: true, // Auto-close OAuth popups on success
265- },
266 Styling: WorkspaceStylingConfig{273 Styling: WorkspaceStylingConfig{
267 BorderWidth: defaultBorderWidth,274 BorderWidth: defaultBorderWidth,
268 BorderColor: defaultBorderColor,275 BorderColor: defaultBorderColor,
M internal/infrastructure/config/loader.go +127 -12
@@ -65,6 +65,9 @@ func NewManager() (*Manager, error) {
65 if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {65 if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
66 return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)66 return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
67 }67 }
68+ if err := bindBrowsingContextEnvAliases(v); err != nil {
69+ return nil, err
70+ }
68 71
69 return &Manager{72 return &Manager{
70 viper: v,73 viper: v,
@@ -145,17 +148,116 @@ func (m *Manager) readConfigFile() error {
145 // transformLegacyConfig converts old-format action bindings and engine config to the new format.148 // transformLegacyConfig converts old-format action bindings and engine config to the new format.
146 // This is called after reading config but before unmarshaling.149 // This is called after reading config but before unmarshaling.
147 func (m *Manager) transformLegacyConfig() {150 func (m *Manager) transformLegacyConfig() {
151+ legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
152+
148 rawConfig := m.viper.AllSettings()153 rawConfig := m.viper.AllSettings()
149 transformer := NewLegacyConfigTransformer()154 transformer := NewLegacyConfigTransformer()
150 transformer.TransformLegacyActions(rawConfig)155 transformer.TransformLegacyActions(rawConfig)
151 transformer.TransformLegacyEngineConfig(rawConfig)156 transformer.TransformLegacyEngineConfig(rawConfig)
152 157
158+ // AllSettings includes defaults, so workspace.browsing_contexts may already be
159+ // present even when the user only configured legacy workspace.popups. Always
160+ // merge legacy popup values field-by-field into the canonical section while
161+ // preserving explicit canonical file/env overrides.
162+ if legacyPopupsInConfig {
163+ m.mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig)
164+ } else {
165+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
166+ }
167+
153 // Apply transformed config back to viper168 // Apply transformed config back to viper
154 for key, value := range rawConfig {169 for key, value := range rawConfig {
155 m.viper.Set(key, value)170 m.viper.Set(key, value)
156 }171 }
157 }172 }
158 173
174+func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {
175+ workspace, ok := rawConfig["workspace"].(map[string]any)
176+ if !ok {
177+ return
178+ }
179+
180+ legacyPopups, ok := workspace["popups"].(map[string]any)
181+ if !ok {
182+ return
183+ }
184+
185+ canonical, ok := workspace["browsing_contexts"].(map[string]any)
186+ if !ok || canonical == nil {
187+ canonical = make(map[string]any, len(legacyPopups))
188+ workspace["browsing_contexts"] = canonical
189+ }
190+
191+ for key, value := range legacyPopups {
192+ if m.hasCanonicalBrowsingContextsOverride(key) {
193+ continue
194+ }
195+ canonical[key] = value
196+ }
197+
198+ delete(workspace, "popups")
199+}
200+
201+func (m *Manager) hasCanonicalBrowsingContextsOverride(field string) bool {
202+ if m.viper.InConfig("workspace.browsing_contexts." + field) {
203+ return true
204+ }
205+ return m.hasCanonicalBrowsingContextsEnvOverride(field)
206+}
207+
208+func (*Manager) hasCanonicalBrowsingContextsEnvOverride(field string) bool {
209+ suffix := strings.ToUpper(strings.ReplaceAll(field, ".", "_"))
210+ for _, envKey := range []string{
211+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_" + suffix,
212+ "DUMBER_WORKSPACE_POPUPS_" + suffix,
213+ } {
214+ if _, ok := os.LookupEnv(envKey); ok {
215+ return true
216+ }
217+ }
218+ return false
219+}
220+
221+func bindBrowsingContextEnvAliases(v *viper.Viper) error {
222+ bindings := map[string][]string{
223+ "workspace.browsing_contexts.behavior": {
224+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_BEHAVIOR",
225+ "DUMBER_WORKSPACE_POPUPS_BEHAVIOR",
226+ },
227+ "workspace.browsing_contexts.placement": {
228+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_PLACEMENT",
229+ "DUMBER_WORKSPACE_POPUPS_PLACEMENT",
230+ },
231+ "workspace.browsing_contexts.open_in_new_pane": {
232+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_OPEN_IN_NEW_PANE",
233+ "DUMBER_WORKSPACE_POPUPS_OPEN_IN_NEW_PANE",
234+ },
235+ "workspace.browsing_contexts.follow_pane_context": {
236+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_FOLLOW_PANE_CONTEXT",
237+ "DUMBER_WORKSPACE_POPUPS_FOLLOW_PANE_CONTEXT",
238+ },
239+ "workspace.browsing_contexts.blank_target_behavior": {
240+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_BLANK_TARGET_BEHAVIOR",
241+ "DUMBER_WORKSPACE_POPUPS_BLANK_TARGET_BEHAVIOR",
242+ },
243+ "workspace.browsing_contexts.enable_smart_detection": {
244+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_ENABLE_SMART_DETECTION",
245+ "DUMBER_WORKSPACE_POPUPS_ENABLE_SMART_DETECTION",
246+ },
247+ "workspace.browsing_contexts.oauth_auto_close": {
248+ "DUMBER_WORKSPACE_BROWSING_CONTEXTS_OAUTH_AUTO_CLOSE",
249+ "DUMBER_WORKSPACE_POPUPS_OAUTH_AUTO_CLOSE",
250+ },
251+ }
252+ for key, envs := range bindings {
253+ args := append([]string{key}, envs...)
254+ if err := v.BindEnv(args...); err != nil {
255+ return fmt.Errorf("failed to bind browsing-context env aliases for %s: %w", key, err)
256+ }
257+ }
258+ return nil
259+}
260+
159 // checkLegacyFormat detects old config format and returns an error directing user to migrate.261 // checkLegacyFormat detects old config format and returns an error directing user to migrate.
160 func (m *Manager) checkLegacyFormat() error {262 func (m *Manager) checkLegacyFormat() error {
161 // Check if old sections exist by looking for known keys.263 // Check if old sections exist by looking for known keys.
@@ -208,6 +310,13 @@ func normalizeConfig(config *Config) {
208 normalizeAppearance(config)310 normalizeAppearance(config)
209 normalizeMedia(config)311 normalizeMedia(config)
210 normalizeEngineConfig(config)312 normalizeEngineConfig(config)
313+ normalizeBrowsingContexts(config)
314+}
315+
316+// normalizeBrowsingContexts ensures the deprecated Popups field mirrors BrowsingContexts
317+// for runtime compatibility. Existing code can keep reading config.Workspace.Popups.
318+func normalizeBrowsingContexts(config *Config) {
319+ config.Workspace.Popups = config.Workspace.BrowsingContexts
211 }320 }
212 321
213 func normalizeEngineConfig(config *Config) {322 func normalizeEngineConfig(config *Config) {
@@ -313,8 +422,13 @@ func (m *Manager) Save(cfg *Config) error {
313 return fmt.Errorf("config is nil")422 return fmt.Errorf("config is nil")
314 }423 }
315 424
425+ // Normalize a copy before writing/publishing so deprecated runtime aliases
426+ // stay in sync even when the watcher skips reload after Save().
427+ cfgToSave := *cfg
428+ normalizeConfig(&cfgToSave)
429+
316 // Validate before writing so UI gets immediate errors.430 // Validate before writing so UI gets immediate errors.
317- if err := validateConfig(cfg); err != nil {431+ if err := validateConfig(&cfgToSave); err != nil {
318 return fmt.Errorf("configuration validation failed: %w", err)432 return fmt.Errorf("configuration validation failed: %w", err)
319 }433 }
320 434
@@ -332,14 +446,15 @@ func (m *Manager) Save(cfg *Config) error {
332 // overwriting our in-memory config with stale viper cache446 // overwriting our in-memory config with stale viper cache
333 m.skipNextReload = true447 m.skipNextReload = true
334 448
335- if err := WriteConfigOrdered(cfg, configFile); err != nil {449+ if err := WriteConfigOrdered(&cfgToSave, configFile); err != nil {
336 m.skipNextReload = false // Reset on error450 m.skipNextReload = false // Reset on error
337 return fmt.Errorf("failed to write config: %w", err)451 return fmt.Errorf("failed to write config: %w", err)
338 }452 }
339 453
340- // Update in-memory config immediately (don't wait for file watcher)454+ // Update in-memory config immediately (don't wait for file watcher).
341- // This ensures subsequent Get() calls return the new values455+ // This ensures subsequent Get() calls return normalized values even when
342- m.config = cfg456+ // the watcher intentionally skips reload for our own Save().
457+ m.config = &cfgToSave
343 458
344 // Also reload from disk to sync Viper's internal state (if not watching)459 // Also reload from disk to sync Viper's internal state (if not watching)
345 if !m.watching {460 if !m.watching {
@@ -500,13 +615,13 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
500 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)615 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
501 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)616 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
502 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)617 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
503- m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))618+ m.viper.SetDefault("workspace.browsing_contexts.behavior", string(defaults.Workspace.BrowsingContexts.Behavior))
504- m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)619+ m.viper.SetDefault("workspace.browsing_contexts.placement", defaults.Workspace.BrowsingContexts.Placement)
505- m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)620+ m.viper.SetDefault("workspace.browsing_contexts.open_in_new_pane", defaults.Workspace.BrowsingContexts.OpenInNewPane)
506- m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)621+ m.viper.SetDefault("workspace.browsing_contexts.follow_pane_context", defaults.Workspace.BrowsingContexts.FollowPaneContext)
507- m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)622+ m.viper.SetDefault("workspace.browsing_contexts.blank_target_behavior", defaults.Workspace.BrowsingContexts.BlankTargetBehavior)
508- m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)623+ m.viper.SetDefault("workspace.browsing_contexts.enable_smart_detection", defaults.Workspace.BrowsingContexts.EnableSmartDetection)
509- m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)624+ m.viper.SetDefault("workspace.browsing_contexts.oauth_auto_close", defaults.Workspace.BrowsingContexts.OAuthAutoClose)
510 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)625 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
511 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)626 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
512 m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)627 m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
M internal/infrastructure/config/migrate.go +88 -13
@@ -87,12 +87,19 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
87 return nil, nil87 return nil, nil
88 }88 }
89 89
90- // Get user-defined keys with values90+ // Get user-defined keys with values after compatibility transforms.
91 userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)91 userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
92 if err != nil {92 if err != nil {
93 return nil, fmt.Errorf("failed to parse user config: %w", err)93 return nil, fmt.Errorf("failed to parse user config: %w", err)
94 }94 }
95 95
96+ // Get raw user-defined keys before popup compatibility transforms so legacy
97+ // workspace.popups -> workspace.browsing_contexts renames are still reported.
98+ rawUserKeysWithValues, err := m.getRawUserConfigKeysWithValues(configFile)
99+ if err != nil {
100+ return nil, fmt.Errorf("failed to parse raw user config: %w", err)
101+ }
102+
96 // Get all default keys103 // Get all default keys
97 defaultKeys := m.getAllDefaultKeys()104 defaultKeys := m.getAllDefaultKeys()
98 defaultKeySet := make(map[string]bool)105 defaultKeySet := make(map[string]bool)
@@ -106,13 +113,22 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
106 userKeySet[k] = true113 userKeySet[k] = true
107 }114 }
108 115
116+ legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)
109 var changes []port.KeyChange117 var changes []port.KeyChange
118+ changes = append(changes, legacyBrowsingContextRenames...)
119+
120+ handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
121+ handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
122+ for _, change := range legacyBrowsingContextRenames {
123+ handledLegacyDeprecatedKeys[change.OldKey] = true
124+ handledLegacyMissingKeys[change.NewKey] = true
125+ }
110 126
111 // Find deprecated keys (in user config but not in defaults)127 // Find deprecated keys (in user config but not in defaults)
112- deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet)128+ deprecatedKeys := excludeKeys(m.findDeprecatedKeys(userKeysWithValues, defaultKeySet), handledLegacyDeprecatedKeys)
113 129
114 // Find missing keys (in defaults but not in user config)130 // Find missing keys (in defaults but not in user config)
115- missingKeys := m.findMissingKeys(defaultKeys, userKeySet)131+ missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
116 missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))132 missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
117 133
118 // Try to match deprecated keys with missing keys (detect renames)134 // Try to match deprecated keys with missing keys (detect renames)
@@ -378,9 +394,11 @@ func (m *Migrator) Migrate() ([]string, error) {
378 }394 }
379 395
380 // Transform legacy action bindings (slice -> ActionBinding struct)396 // Transform legacy action bindings (slice -> ActionBinding struct)
397+ // and migrate popups -> browsing_contexts
381 transformer := NewLegacyConfigTransformer()398 transformer := NewLegacyConfigTransformer()
382 transformer.TransformLegacyActions(rawConfig)399 transformer.TransformLegacyActions(rawConfig)
383 transformer.TransformLegacyEngineConfig(rawConfig)400 transformer.TransformLegacyEngineConfig(rawConfig)
401+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
384 402
385 // Build sets of keys to remove and renames to apply403 // Build sets of keys to remove and renames to apply
386 keysToRemove := make(map[string]bool)404 keysToRemove := make(map[string]bool)
@@ -657,29 +675,71 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
657 675
658 // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.676 // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
659 func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {677 func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
660- data, err := os.ReadFile(configFile)678+ rawConfig, err := m.readRawConfig(configFile)
661 if err != nil {679 if err != nil {
662- return nil, fmt.Errorf("failed to read config file: %w", err)680+ return nil, err
663- }
664-
665- // Parse TOML into a generic map
666- var rawConfig map[string]any
667- if err := toml.Unmarshal(data, &rawConfig); err != nil {
668- return nil, fmt.Errorf("failed to parse TOML: %w", err)
669 }681 }
670 682
671- // Transform legacy action bindings before flattening683+ // Transform legacy action bindings and popups before flattening.
672 transformer := NewLegacyConfigTransformer()684 transformer := NewLegacyConfigTransformer()
673 transformer.TransformLegacyActions(rawConfig)685 transformer.TransformLegacyActions(rawConfig)
674 transformer.TransformLegacyEngineConfig(rawConfig)686 transformer.TransformLegacyEngineConfig(rawConfig)
687+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
675 688
676- // Flatten the map to dot-notation keys with values689+ // Flatten the map to dot-notation keys with values.
677 result := make(map[string]any)690 result := make(map[string]any)
678 m.flattenMapWithValues(rawConfig, "", result)691 m.flattenMapWithValues(rawConfig, "", result)
679 692
680 return result, nil693 return result, nil
681 }694 }
682 695
696+func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string]any, error) {
697+ rawConfig, err := m.readRawConfig(configFile)
698+ if err != nil {
699+ return nil, err
700+ }
701+
702+ result := make(map[string]any)
703+ m.flattenMapWithValues(rawConfig, "", result)
704+ return result, nil
705+}
706+
707+func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
708+ type renamePair struct {
709+ oldKey string
710+ newKey string
711+ }
712+
713+ renamePairs := []renamePair{
714+ {"workspace.popups.behavior", "workspace.browsing_contexts.behavior"},
715+ {"workspace.popups.placement", "workspace.browsing_contexts.placement"},
716+ {"workspace.popups.open_in_new_pane", "workspace.browsing_contexts.open_in_new_pane"},
717+ {"workspace.popups.follow_pane_context", "workspace.browsing_contexts.follow_pane_context"},
718+ {"workspace.popups.blank_target_behavior", "workspace.browsing_contexts.blank_target_behavior"},
719+ {"workspace.popups.enable_smart_detection", "workspace.browsing_contexts.enable_smart_detection"},
720+ {"workspace.popups.oauth_auto_close", "workspace.browsing_contexts.oauth_auto_close"},
721+ }
722+
723+ changes := make([]port.KeyChange, 0, len(renamePairs))
724+ for _, pair := range renamePairs {
725+ oldValue, hasOld := rawUserKeys[pair.oldKey]
726+ if !hasOld {
727+ continue
728+ }
729+ if _, hasNew := rawUserKeys[pair.newKey]; hasNew {
730+ continue
731+ }
732+ changes = append(changes, port.KeyChange{
733+ Type: port.KeyChangeRenamed,
734+ OldKey: pair.oldKey,
735+ NewKey: pair.newKey,
736+ OldValue: m.formatValue(oldValue),
737+ NewValue: m.formatValue(m.defaultValueForKey(pair.newKey)),
738+ })
739+ }
740+ return changes
741+}
742+
683 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.743 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
684 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {744 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
685 for k, v := range data {745 for k, v := range data {
@@ -837,6 +897,21 @@ func appendUniqueKeys(base, extra []string) []string {
837 return base897 return base
838 }898 }
839 899
900+func excludeKeys(keys []string, excluded map[string]bool) []string {
901+ if len(excluded) == 0 {
902+ return keys
903+ }
904+
905+ filtered := make([]string, 0, len(keys))
906+ for _, key := range keys {
907+ if excluded[key] {
908+ continue
909+ }
910+ filtered = append(filtered, key)
911+ }
912+ return filtered
913+}
914+
840 func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {915 func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
841 if value == nil {916 if value == nil {
842 return nil, false917 return nil, false
M internal/infrastructure/config/migrate_test.go +197 -0
@@ -3,8 +3,10 @@ package config
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
7 8
9+ port "github.com/bnema/dumber/internal/application/port"
8 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
10 )12 )
@@ -916,6 +918,201 @@ func TestMigrator_DeleteNestedKey(t *testing.T) {
916 })918 })
917 }919 }
918 920
921+// --- Migrate-path popup → browsing_contexts transform tests ---
922+
923+func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
924+ m := NewMigrator()
925+
926+ tmpDir := t.TempDir()
927+ configFile := filepath.Join(tmpDir, "config.toml")
928+
929+ content := `
930+[workspace]
931+new_pane_url = "about:blank"
932+
933+[workspace.popups]
934+behavior = "tabbed"
935+placement = "left"
936+oauth_auto_close = false
937+`
938+ err := os.WriteFile(configFile, []byte(content), 0o644)
939+ require.NoError(t, err)
940+
941+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
942+ require.NoError(t, err)
943+
944+ // Legacy popups keys should NOT appear after transform.
945+ _, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
946+ assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
947+
948+ // Browsing_contexts flattened keys should carry the legacy values.
949+ assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
950+ assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
951+ assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
952+}
953+
954+func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
955+ m := NewMigrator()
956+
957+ tmpDir := t.TempDir()
958+ configFile := filepath.Join(tmpDir, "config.toml")
959+
960+ // Both popups and browsing_contexts present — transform should be a no-op for popups.
961+ content := `
962+[workspace]
963+new_pane_url = "about:blank"
964+
965+[workspace.popups]
966+behavior = "tabbed"
967+
968+[workspace.browsing_contexts]
969+behavior = "split"
970+placement = "right"
971+`
972+ err := os.WriteFile(configFile, []byte(content), 0o644)
973+ require.NoError(t, err)
974+
975+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
976+ require.NoError(t, err)
977+
978+ // Browsing_contexts flattened keys should retain their original values.
979+ assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
980+ assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
981+
982+ // Popups should still exist (transform skipped when browsing_contexts already present).
983+ _, hasPopups := keysWithValues["workspace.popups.behavior"]
984+ assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
985+}
986+
987+func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
988+ m := NewMigrator()
989+
990+ tmpDir := t.TempDir()
991+ configFile := filepath.Join(tmpDir, "config.toml")
992+
993+ content := `
994+[workspace]
995+new_pane_url = "about:blank"
996+
997+[workspace.browsing_contexts]
998+behavior = "stacked"
999+`
1000+ err := os.WriteFile(configFile, []byte(content), 0o644)
1001+ require.NoError(t, err)
1002+
1003+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1004+ require.NoError(t, err)
1005+
1006+ // Should only have browsing_contexts, no popups.
1007+ assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1008+
1009+ _, hasPopups := keysWithValues["workspace.popups.behavior"]
1010+ assert.False(t, hasPopups, "popups should not exist when absent from config")
1011+}
1012+
1013+func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1014+ home := t.TempDir()
1015+ t.Setenv("HOME", home)
1016+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1017+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1018+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1019+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1020+
1021+ configFile, err := GetConfigFile()
1022+ require.NoError(t, err)
1023+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1024+
1025+ content, err := encodeConfigToTOML(DefaultConfig())
1026+ require.NoError(t, err)
1027+ content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1028+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1029+
1030+ m := NewMigrator()
1031+ changes, err := m.DetectChanges()
1032+ require.NoError(t, err)
1033+
1034+ found := false
1035+ for _, change := range changes {
1036+ if change.Type == port.KeyChangeRenamed &&
1037+ change.OldKey == "workspace.popups.behavior" &&
1038+ change.NewKey == "workspace.browsing_contexts.behavior" {
1039+ found = true
1040+ break
1041+ }
1042+ }
1043+ assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1044+}
1045+
1046+func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1047+ home := t.TempDir()
1048+ t.Setenv("HOME", home)
1049+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1050+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1051+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1052+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1053+
1054+ configFile, err := GetConfigFile()
1055+ require.NoError(t, err)
1056+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1057+
1058+ content, err := encodeConfigToTOML(DefaultConfig())
1059+ require.NoError(t, err)
1060+ content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1061+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1062+
1063+ m := NewMigrator()
1064+ appliedKeys, err := m.Migrate()
1065+ require.NoError(t, err)
1066+ assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1067+
1068+ migrated, err := os.ReadFile(configFile)
1069+ require.NoError(t, err)
1070+ assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1071+ assert.NotContains(t, string(migrated), "[workspace.popups]")
1072+}
1073+
1074+func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1075+ home := t.TempDir()
1076+ t.Setenv("HOME", home)
1077+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1078+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1079+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1080+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1081+
1082+ configFile, err := GetConfigFile()
1083+ require.NoError(t, err)
1084+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1085+ require.NoError(t, os.WriteFile(configFile, []byte(`
1086+[workspace.popups]
1087+placement = "left"
1088+
1089+[workspace.browsing_contexts]
1090+behavior = "tabbed"
1091+`), 0o644))
1092+
1093+ m := NewMigrator()
1094+ changes, err := m.DetectChanges()
1095+ require.NoError(t, err)
1096+
1097+ renameCount := 0
1098+ for _, change := range changes {
1099+ if change.Type == port.KeyChangeRenamed &&
1100+ change.OldKey == "workspace.popups.placement" &&
1101+ change.NewKey == "workspace.browsing_contexts.placement" {
1102+ renameCount++
1103+ }
1104+ assert.False(t,
1105+ change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1106+ "legacy popup placement should be reported as a rename, not also a removal",
1107+ )
1108+ assert.False(t,
1109+ change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1110+ "canonical browsing_contexts placement should be reported as a rename, not also an addition",
1111+ )
1112+ }
1113+ assert.Equal(t, 1, renameCount)
1114+}
1115+
919 func TestMigrator_ReadRawConfig(t *testing.T) {1116 func TestMigrator_ReadRawConfig(t *testing.T) {
920 m := NewMigrator()1117 m := NewMigrator()
921 1118
M internal/infrastructure/config/schema.go +5 -4
@@ -68,8 +68,11 @@ const (
68 OmniboxInitialBehaviorNone = entity.OmniboxInitialBehaviorNone68 OmniboxInitialBehaviorNone = entity.OmniboxInitialBehaviorNone
69 )69 )
70 70
71-// PopupBehaviorConfig defines handling for popup windows.71+// BrowsingContextConfig defines handling for browsing contexts (popups, tabs, new windows).
72-type PopupBehaviorConfig = entity.PopupBehaviorConfig72+type BrowsingContextConfig = entity.BrowsingContextConfig
73+
74+// Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
75+type PopupBehaviorConfig = entity.BrowsingContextConfig
73 76
74 // WorkspaceConfig captures layout, pane, and tab behavior preferences.77 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
75 type WorkspaceConfig = entity.WorkspaceConfig78 type WorkspaceConfig = entity.WorkspaceConfig
@@ -381,5 +384,3 @@ type DownloadsConfig struct {
381 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.384 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
382 Path string `mapstructure:"path" yaml:"path" toml:"path"`385 Path string `mapstructure:"path" yaml:"path" toml:"path"`
383 }386 }
384-
385-
M internal/infrastructure/config/schema_provider.go +15 -15
@@ -485,56 +485,56 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
485 Description: "Named floating pane URL profiles with keys, url, and optional desc",485 Description: "Named floating pane URL profiles with keys, url, and optional desc",
486 Section: SectionWorkspace,486 Section: SectionWorkspace,
487 },487 },
488- // Popups488+ // Browsing contexts
489 {489 {
490- Key: "workspace.popups.behavior",490+ Key: "workspace.browsing_contexts.behavior",
491 Type: "string",491 Type: "string",
492- Default: string(defaults.Workspace.Popups.Behavior),492+ Default: string(defaults.Workspace.BrowsingContexts.Behavior),
493 Description: "How to handle JavaScript popup windows",493 Description: "How to handle JavaScript popup windows",
494 Values: []string{"split", "stacked", "tabbed", "windowed"},494 Values: []string{"split", "stacked", "tabbed", "windowed"},
495 Section: SectionWorkspace,495 Section: SectionWorkspace,
496 },496 },
497 {497 {
498- Key: "workspace.popups.placement",498+ Key: "workspace.browsing_contexts.placement",
499 Type: "string",499 Type: "string",
500- Default: defaults.Workspace.Popups.Placement,500+ Default: defaults.Workspace.BrowsingContexts.Placement,
501 Description: "Placement direction for split popups",501 Description: "Placement direction for split popups",
502 Values: []string{"right", "left", "top", "bottom"},502 Values: []string{"right", "left", "top", "bottom"},
503 Section: SectionWorkspace,503 Section: SectionWorkspace,
504 },504 },
505 {505 {
506- Key: "workspace.popups.blank_target_behavior",506+ Key: "workspace.browsing_contexts.blank_target_behavior",
507 Type: "string",507 Type: "string",
508- Default: defaults.Workspace.Popups.BlankTargetBehavior,508+ Default: defaults.Workspace.BrowsingContexts.BlankTargetBehavior,
509 Description: "How to handle target=\"_blank\" links",509 Description: "How to handle target=\"_blank\" links",
510 Values: []string{"split", "stacked", "tabbed"},510 Values: []string{"split", "stacked", "tabbed"},
511 Section: SectionWorkspace,511 Section: SectionWorkspace,
512 },512 },
513 {513 {
514- Key: "workspace.popups.open_in_new_pane",514+ Key: "workspace.browsing_contexts.open_in_new_pane",
515 Type: "bool",515 Type: "bool",
516- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OpenInNewPane),516+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.OpenInNewPane),
517 Description: "Open popups in workspace instead of blocking",517 Description: "Open popups in workspace instead of blocking",
518 Section: SectionWorkspace,518 Section: SectionWorkspace,
519 },519 },
520 {520 {
521- Key: "workspace.popups.follow_pane_context",521+ Key: "workspace.browsing_contexts.follow_pane_context",
522 Type: "bool",522 Type: "bool",
523- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.FollowPaneContext),523+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.FollowPaneContext),
524 Description: "Popup placement follows parent pane context",524 Description: "Popup placement follows parent pane context",
525 Section: SectionWorkspace,525 Section: SectionWorkspace,
526 },526 },
527 {527 {
528- Key: "workspace.popups.enable_smart_detection",528+ Key: "workspace.browsing_contexts.enable_smart_detection",
529 Type: "bool",529 Type: "bool",
530- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.EnableSmartDetection),530+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.EnableSmartDetection),
531 Description: "Use window properties to detect popup intent",531 Description: "Use window properties to detect popup intent",
532 Section: SectionWorkspace,532 Section: SectionWorkspace,
533 },533 },
534 {534 {
535- Key: "workspace.popups.oauth_auto_close",535+ Key: "workspace.browsing_contexts.oauth_auto_close",
536 Type: "bool",536 Type: "bool",
537- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OAuthAutoClose),537+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.OAuthAutoClose),
538 Description: "Auto-close OAuth popups after success",538 Description: "Auto-close OAuth popups after success",
539 Section: SectionWorkspace,539 Section: SectionWorkspace,
540 },540 },
M internal/infrastructure/config/transform_legacy.go +37 -0
@@ -97,6 +97,43 @@ func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]an
97 }97 }
98 }98 }
99 99
100+// TransformLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
101+// workspace.browsing_contexts.* when the new section is absent. This allows
102+// existing user configs with [workspace.popups] to be silently migrated.
103+func (t *LegacyConfigTransformer) TransformLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
104+ workspace, ok := rawConfig["workspace"].(map[string]any)
105+ if !ok {
106+ return
107+ }
108+
109+ // Only transform if the new key does not already exist.
110+ if _, hasNew := workspace["browsing_contexts"]; hasNew {
111+ return
112+ }
113+
114+ t.ForceLegacyPopupsToBrowsingContexts(rawConfig)
115+}
116+
117+// ForceLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
118+// workspace.browsing_contexts.* even when the new section already exists in the
119+// raw map. This is used by the loader because Viper's AllSettings includes
120+// defaults for the new section that do not indicate user intent.
121+func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
122+ workspace, ok := rawConfig["workspace"].(map[string]any)
123+ if !ok {
124+ return
125+ }
126+
127+ oldPopups, ok := workspace["popups"]
128+ if !ok {
129+ return
130+ }
131+
132+ // Move the popups map to browsing_contexts and delete the old key.
133+ workspace["browsing_contexts"] = oldPopups
134+ delete(workspace, "popups")
135+}
136+
100 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {137 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
101 switch {138 switch {
102 case path[0] == sectionWorkspace && path[1] == "pane_mode":139 case path[0] == sectionWorkspace && path[1] == "pane_mode":
M internal/infrastructure/config/validation.go +12 -10
@@ -117,32 +117,34 @@ func validateSearchEngine(config *Config) []string {
117 117
118 func validatePopups(config *Config) []string {118 func validatePopups(config *Config) []string {
119 var validationErrors []string119 var validationErrors []string
120- switch config.Workspace.Popups.Behavior {120+ // Validate against BrowsingContexts (canonical field).
121+ // Popups is a runtime alias synced during normalization.
122+ switch config.Workspace.BrowsingContexts.Behavior {
121 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:123 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
122 default:124 default:
123 validationErrors = append(validationErrors, fmt.Sprintf(125 validationErrors = append(validationErrors, fmt.Sprintf(
124- "workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",126+ "workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
125- config.Workspace.Popups.Behavior,127+ config.Workspace.BrowsingContexts.Behavior,
126 ))128 ))
127 }129 }
128 130
129- if config.Workspace.Popups.Behavior == PopupBehaviorSplit {131+ if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit {
130- switch config.Workspace.Popups.Placement {132+ switch config.Workspace.BrowsingContexts.Placement {
131 case "right", "left", "top", "bottom":133 case "right", "left", "top", "bottom":
132 default:134 default:
133 validationErrors = append(validationErrors, fmt.Sprintf(135 validationErrors = append(validationErrors, fmt.Sprintf(
134- "workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",136+ "workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)",
135- config.Workspace.Popups.Placement,137+ config.Workspace.BrowsingContexts.Placement,
136 ))138 ))
137 }139 }
138 }140 }
139 141
140- switch config.Workspace.Popups.BlankTargetBehavior {142+ switch config.Workspace.BrowsingContexts.BlankTargetBehavior {
141 case "split", "stacked", "tabbed":143 case "split", "stacked", "tabbed":
142 default:144 default:
143 validationErrors = append(validationErrors, fmt.Sprintf(145 validationErrors = append(validationErrors, fmt.Sprintf(
144- "workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",146+ "workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
145- config.Workspace.Popups.BlankTargetBehavior,147+ config.Workspace.BrowsingContexts.BlankTargetBehavior,
146 ))148 ))
147 }149 }
148 return validationErrors150 return validationErrors
M internal/infrastructure/config/watcher.go +2 -0
@@ -87,6 +87,8 @@ func (m *Manager) reload() error {
87 return err87 return err
88 }88 }
89 89
90+ m.transformLegacyConfig()
91+
90 config := &Config{}92 config := &Config{}
91 if err := m.viper.Unmarshal(config); err != nil {93 if err := m.viper.Unmarshal(config); err != nil {
92 return err94 return err
M internal/infrastructure/webkit/webview.go +53 -15
@@ -10,6 +10,7 @@ import (
10 "sync/atomic"10 "sync/atomic"
11 "time"11 "time"
12 12
13+ "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
14 downloadutil "github.com/bnema/dumber/internal/domain/download"15 downloadutil "github.com/bnema/dumber/internal/domain/download"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
@@ -147,20 +148,23 @@ type WebView struct {
147 generation atomic.Uint64148 generation atomic.Uint64
148 149
149 // Callbacks (set by UI layer)150 // Callbacks (set by UI layer)
150- OnLoadChanged func(LoadEvent)151+ OnLoadChanged func(LoadEvent)
151- OnTitleChanged func(string)152+ OnTitleChanged func(string)
152- OnURIChanged func(string)153+ OnURIChanged func(string)
153- OnProgressChanged func(float64)154+ OnProgressChanged func(float64)
154- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes155+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
155- OnClose func()156+ OnClose func()
156- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup157+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
157- OnReadyToShow func() // Called when popup is ready to display158+ OnReadyToShow func() // Called when popup is ready to display
158- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)159+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
159- OnEnterFullscreen func() bool // Return true to prevent fullscreen160+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
160- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen161+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
161- OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops162+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
162- OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)163+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
163- OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)164+ OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
165+ browsingContextDecision dto.HostDecision
166+ hasBrowsingContextDecision bool
167+ nativePopupHostAbort func()
164 168
165 // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).169 // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
166 // Return true to indicate the request was handled. Call allow()/deny() to respond.170 // Return true to indicate the request was handled. Call allow()/deny() to respond.
@@ -518,6 +522,34 @@ func (wv *WebView) connectCloseSignal() {
518 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
519 }523 }
520 524
525+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
526+ wv.mu.Lock()
527+ wv.browsingContextDecision = decision
528+ wv.hasBrowsingContextDecision = true
529+ wv.mu.Unlock()
530+}
531+
532+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
533+ wv.mu.RLock()
534+ defer wv.mu.RUnlock()
535+ return wv.browsingContextDecision, wv.hasBrowsingContextDecision
536+}
537+
538+func (wv *WebView) SetNativePopupHostAbort(fn func()) {
539+ wv.mu.Lock()
540+ wv.nativePopupHostAbort = fn
541+ wv.mu.Unlock()
542+}
543+
544+func (wv *WebView) AbortNativePopupHost() {
545+ wv.mu.RLock()
546+ fn := wv.nativePopupHostAbort
547+ wv.mu.RUnlock()
548+ if fn != nil {
549+ fn()
550+ }
551+}
552+
521 func (wv *WebView) connectCreateSignal() {553 func (wv *WebView) connectCreateSignal() {
522 createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {554 createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
523 if wv.OnCreate == nil {555 if wv.OnCreate == nil {
@@ -1893,9 +1925,12 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1893 wv.OnWebProcessTerminated = nil1925 wv.OnWebProcessTerminated = nil
1894 wv.OnPermissionRequest = nil1926 wv.OnPermissionRequest = nil
1895 1927
1896- // 3. Clear async callback references1928+ // 3. Clear async callback references and popup-hosting state
1897 wv.mu.Lock()1929 wv.mu.Lock()
1898 wv.asyncCallbacks = nil1930 wv.asyncCallbacks = nil
1931+ wv.browsingContextDecision = dto.HostDecision{}
1932+ wv.hasBrowsingContextDecision = false
1933+ wv.nativePopupHostAbort = nil
1899 wv.mu.Unlock()1934 wv.mu.Unlock()
1900 1935
1901 // 4. Unparent from GTK hierarchy (must happen before process termination)1936 // 4. Unparent from GTK hierarchy (must happen before process termination)
@@ -1963,6 +1998,9 @@ func (wv *WebView) ResetForPoolReuse() {
1963 wv.isLoading = false1998 wv.isLoading = false
1964 wv.asyncCallbacks = nil1999 wv.asyncCallbacks = nil
1965 wv.runJSErrorStats = make(map[string]runJSErrorStat)2000 wv.runJSErrorStats = make(map[string]runJSErrorStat)
2001+ wv.browsingContextDecision = dto.HostDecision{}
2002+ wv.hasBrowsingContextDecision = false
2003+ wv.nativePopupHostAbort = nil
1966 wv.lastProgressUpdate.Store(0)2004 wv.lastProgressUpdate.Store(0)
1967 wv.mu.Unlock()2005 wv.mu.Unlock()
1968 2006
A internal/infrastructure/webkit/webview_reuse_state_test.go +42 -0
@@ -0,0 +1,42 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) {
11+ wv := &WebView{}
12+ abortCalled := false
13+
14+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
15+ wv.SetNativePopupHostAbort(func() { abortCalled = true })
16+
17+ wv.ResetForPoolReuse()
18+
19+ decision, hasDecision := wv.BrowsingContextHostDecision()
20+ assert.False(t, hasDecision)
21+ assert.Equal(t, dto.HostDecision{}, decision)
22+
23+ wv.AbortNativePopupHost()
24+ assert.False(t, abortCalled)
25+}
26+
27+func TestDestroyWithPolicy_ClearsBrowsingContextHostState(t *testing.T) {
28+ wv := &WebView{}
29+ abortCalled := false
30+
31+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
32+ wv.SetNativePopupHostAbort(func() { abortCalled = true })
33+
34+ wv.DestroyWithPolicy(terminatePolicyNever)
35+
36+ decision, hasDecision := wv.BrowsingContextHostDecision()
37+ assert.False(t, hasDecision)
38+ assert.Equal(t, dto.HostDecision{}, decision)
39+
40+ wv.AbortNativePopupHost()
41+ assert.False(t, abortCalled)
42+}
M internal/ui/app.go +25 -1
@@ -79,6 +79,7 @@ type App struct {
79 browserWindows map[string]*browserWindow79 browserWindows map[string]*browserWindow
80 browserWindowOrder []string // registration order of window IDs80 browserWindowOrder []string // registration order of window IDs
81 lastFocusedWindowID string81 lastFocusedWindowID string
82+ nativePopupWindows map[port.WebViewID]*nativePopupWindow
82 browserWindowFactory func(context.Context, string) (*browserWindow, error)83 browserWindowFactory func(context.Context, string) (*browserWindow, error)
83 dispatchOnMainThread func(func())84 dispatchOnMainThread func(func())
84 85
@@ -2918,6 +2919,15 @@ func (a *App) onShutdown(ctx context.Context) {
2918 for _, tabID := range tabIDs {2919 for _, tabID := range tabIDs {
2919 a.releaseFloatingSessionsForTab(ctx, tabID)2920 a.releaseFloatingSessionsForTab(ctx, tabID)
2920 }2921 }
2922+ if len(a.nativePopupWindows) > 0 {
2923+ popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows))
2924+ for popupID := range a.nativePopupWindows {
2925+ popupIDs = append(popupIDs, popupID)
2926+ }
2927+ for _, popupID := range popupIDs {
2928+ a.releaseNativePopupWindow(popupID, false, true)
2929+ }
2930+ }
2921 if a.engine != nil {2931 if a.engine != nil {
2922 if err := a.engine.Close(); err != nil {2932 if err := a.engine.Close(); err != nil {
2923 log.Warn().Err(err).Msg("failed to close engine")2933 log.Warn().Err(err).Msg("failed to close engine")
@@ -3097,15 +3107,29 @@ func (a *App) initCoordinators(ctx context.Context) {
3097 }3107 }
3098 a.contentCoord.SetPopupConfig(3108 a.contentCoord.SetPopupConfig(
3099 a.engine.Factory(),3109 a.engine.Factory(),
3100- &a.deps.Config.Workspace.Popups,3110+ &a.deps.Config.Workspace.BrowsingContexts,
3101 a.generateID,3111 a.generateID,
3102 )3112 )
3113+ a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
3114+ bw := a.browserWindowForAnyPane(paneID)
3115+ if bw == nil {
3116+ return "", false
3117+ }
3118+ return bw.id, true
3119+ })
3103 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {3120 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
3121+ if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
3122+ a.activateBrowserWindow(bw)
3123+ }
3104 return a.wsCoord.InsertPopup(ctx, input)3124 return a.wsCoord.InsertPopup(ctx, input)
3105 })3125 })
3106 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {3126 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
3127+ if bw := a.browserWindowForAnyPane(paneID); bw != nil {
3128+ a.activateBrowserWindow(bw)
3129+ }
3107 return a.wsCoord.ClosePaneByID(ctx, paneID)3130 return a.wsCoord.ClosePaneByID(ctx, paneID)
3108 })3131 })
3132+ a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow)
3109 // Wire tabbed popup behavior to create new tabs in the originating window.3133 // Wire tabbed popup behavior to create new tabs in the originating window.
3110 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)3134 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
3111 3135
M internal/ui/browser_window.go +3 -0
@@ -287,6 +287,9 @@ func (a *App) removeBrowserWindow(id string) {
287 if removed != nil {287 if removed != nil {
288 removed.clearShellState()288 removed.clearShellState()
289 }289 }
290+ if a.contentCoord != nil {
291+ a.contentCoord.ClearPopupNamedContextsForWindow(id)
292+ }
290 delete(a.browserWindows, id)293 delete(a.browserWindows, id)
291 fallback := a.deterministicBrowserWindowFallback()294 fallback := a.deterministicBrowserWindowFallback()
292 if a.lastFocusedWindowID == id {295 if a.lastFocusedWindowID == id {
A internal/ui/coordinator/content/auth_window_observer.go +71 -0
@@ -0,0 +1,71 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk/v4/glib"
11+)
12+
13+const nativePopupOAuthCloseDelay = 500 * time.Millisecond
14+
15+var scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
16+ time.AfterFunc(delay, func() {
17+ cb := glib.SourceFunc(func(_ uintptr) bool {
18+ fn()
19+ return false
20+ })
21+ glib.IdleAdd(&cb, 0)
22+ })
23+}
24+
25+func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePopupInput) {
26+ if c == nil || input.PopupWebView == nil || !IsOAuthURL(input.TargetURI) {
27+ return
28+ }
29+
30+ popupID := input.PopupWebView.ID()
31+
32+ oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable)
33+ if !ok {
34+ logging.FromContext(ctx).Debug().
35+ Uint64("popup_id", uint64(popupID)).
36+ Msg("native popup auth observation skipped: webview lacks OAuth callbacks")
37+ return
38+ }
39+ c.trackOAuthPopup(popupID, input.ParentPaneID, input.ParentURIAtOpen)
40+
41+ var requestCloseOnce sync.Once
42+ requestClose := func(reason string) {
43+ requestCloseOnce.Do(func() {
44+ logging.FromContext(ctx).Info().
45+ Uint64("popup_id", uint64(popupID)).
46+ Str("reason", reason).
47+ Msg("native popup oauth callback detected, closing popup")
48+ scheduleNativePopupOAuthClose(nativePopupOAuthCloseDelay, func() {
49+ if input.PopupWebView != nil && !input.PopupWebView.IsDestroyed() {
50+ oauthWV.Close()
51+ }
52+ })
53+ })
54+ }
55+
56+ oauthWV.AddNavigationCallback(func(uri string) {
57+ if !ShouldAutoClose(uri) {
58+ return
59+ }
60+ c.capturePopupOAuthState(popupID, uri)
61+ requestClose("navigation")
62+ })
63+ oauthWV.AddCloseCallback(func() {
64+ c.handlePopupOAuthClose(context.Background(), popupID)
65+ })
66+
67+ logging.FromContext(ctx).Debug().
68+ Uint64("popup_id", uint64(popupID)).
69+ Str("target_uri", logging.TruncateURL(input.TargetURI, logURLMaxLen)).
70+ Msg("native popup auth observer configured")
71+}
A internal/ui/coordinator/content/browsing_context_policy.go +136 -0
@@ -0,0 +1,136 @@
1+package content
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
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+ }
51+
52+ decision.Kind = dto.HostDecisionCreatePane
53+ decision.BrowsingContextName = name
54+ decision.Reason = "pane-hosted browsing context"
55+ return decision
56+}
57+
58+func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest {
59+ return dto.NewBrowsingContextRequest{
60+ ParentWebViewID: uint64(req.ParentViewID),
61+ SourceBrowserID: req.SourceBrowserID,
62+ SourceFrameID: req.SourceFrameID,
63+ SourceFrameURL: req.SourceFrameURL,
64+ TargetURI: req.TargetURI,
65+ TargetFrameName: req.FrameName,
66+ TargetDisposition: inferPopupWindowDisposition(req),
67+ IsUserGesture: req.IsUserGesture,
68+ NoJavaScriptAccess: req.NoJavaScriptAccess,
69+ WindowFeatures: req.WindowFeatures,
70+ TriggerKind: inferPopupTriggerKind(req),
71+ AuthIntent: IsOAuthURL(req.TargetURI),
72+ RequiresNativeOpener: false,
73+ RequestContextDisposition: dto.RequestContextInheritParent,
74+ }
75+}
76+
77+func buildLinkBrowsingContextRequest(parentWebViewID port.WebViewID, uri string) dto.NewBrowsingContextRequest {
78+ return dto.NewBrowsingContextRequest{
79+ ParentWebViewID: uint64(parentWebViewID),
80+ TargetURI: uri,
81+ TargetFrameName: "_blank",
82+ TargetDisposition: dto.WindowDispositionNewTab,
83+ IsUserGesture: true,
84+ NoJavaScriptAccess: true,
85+ TriggerKind: dto.TriggerLinkNewPage,
86+ AuthIntent: IsOAuthURL(uri),
87+ RequestContextDisposition: dto.RequestContextInheritParent,
88+ }
89+}
90+
91+func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
92+ if req.TargetDisposition != "" {
93+ return req.TargetDisposition
94+ }
95+ if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
96+ return dto.WindowDispositionNewTab
97+ }
98+ return dto.WindowDispositionNewPopup
99+}
100+
101+func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
102+ 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+ }
112+ return dto.TriggerLinkNewPage
113+ case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
114+ if reusableBrowsingContextName(req.FrameName) != "" {
115+ return dto.TriggerNamedTargetNavigation
116+ }
117+ return dto.TriggerScriptWindowOpen
118+ default:
119+ if reusableBrowsingContextName(req.FrameName) != "" {
120+ return dto.TriggerNamedTargetNavigation
121+ }
122+ return dto.TriggerUnknown
123+ }
124+}
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+}
A internal/ui/coordinator/content/browsing_context_policy_test.go +174 -0
@@ -0,0 +1,174 @@
1+package content
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestBrowsingContextPolicyDecide(t *testing.T) {
12+ t.Parallel()
13+
14+ policy := browsingContextPolicy{}
15+ tests := []struct {
16+ name string
17+ request dto.NewBrowsingContextRequest
18+ namedContextExist bool
19+ wantKind dto.HostDecisionKind
20+ wantName string
21+ }{
22+ {
23+ name: "blank target stays pane-hosted",
24+ request: dto.NewBrowsingContextRequest{
25+ TargetURI: "https://example.com/docs",
26+ TargetFrameName: "_blank",
27+ TriggerKind: dto.TriggerLinkNewPage,
28+ TargetDisposition: dto.WindowDispositionNewTab,
29+ RequestContextDisposition: dto.RequestContextInheritParent,
30+ },
31+ wantKind: dto.HostDecisionCreatePane,
32+ },
33+ {
34+ name: "named target reuses existing pane in same window",
35+ request: dto.NewBrowsingContextRequest{
36+ TargetURI: "https://example.com/second",
37+ TargetFrameName: "shared-pane",
38+ TriggerKind: dto.TriggerNamedTargetNavigation,
39+ TargetDisposition: dto.WindowDispositionNewPopup,
40+ RequestContextDisposition: dto.RequestContextInheritParent,
41+ },
42+ namedContextExist: true,
43+ wantKind: dto.HostDecisionReuseNamedPane,
44+ wantName: "shared-pane",
45+ },
46+ {
47+ name: "ordinary window.open stays pane-hosted",
48+ request: dto.NewBrowsingContextRequest{
49+ TargetURI: "https://example.com/help",
50+ TriggerKind: dto.TriggerScriptWindowOpen,
51+ TargetDisposition: dto.WindowDispositionNewPopup,
52+ NoJavaScriptAccess: false,
53+ RequestContextDisposition: dto.RequestContextInheritParent,
54+ },
55+ wantKind: dto.HostDecisionCreatePane,
56+ },
57+ {
58+ name: "auth intent forces native window",
59+ request: dto.NewBrowsingContextRequest{
60+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
61+ TriggerKind: dto.TriggerScriptWindowOpen,
62+ TargetDisposition: dto.WindowDispositionNewPopup,
63+ AuthIntent: true,
64+ RequestContextDisposition: dto.RequestContextInheritParent,
65+ },
66+ wantKind: dto.HostDecisionCreateNativeWin,
67+ },
68+ {
69+ name: "ambiguous opener-coupled request prefers native",
70+ request: dto.NewBrowsingContextRequest{
71+ TargetURI: "https://example.com/popup",
72+ TriggerKind: dto.TriggerUnknown,
73+ TargetDisposition: dto.WindowDispositionNewPopup,
74+ NoJavaScriptAccess: false,
75+ RequestContextDisposition: dto.RequestContextInheritParent,
76+ },
77+ wantKind: dto.HostDecisionCreateNativeWin,
78+ },
79+ {
80+ name: "empty target is denied",
81+ request: dto.NewBrowsingContextRequest{
82+ TargetURI: "",
83+ TriggerKind: dto.TriggerLinkNewPage,
84+ RequestContextDisposition: dto.RequestContextInheritParent,
85+ },
86+ wantKind: dto.HostDecisionDeny,
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ t.Parallel()
93+ got := policy.Decide(tt.request, tt.namedContextExist)
94+ assert.Equal(t, tt.wantKind, got.Kind)
95+ assert.Equal(t, tt.wantName, got.BrowsingContextName)
96+ assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
97+ })
98+ }
99+}
100+
101+func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) {
102+ t.Parallel()
103+
104+ req := port.PopupRequest{
105+ TargetURI: "https://example.com/window-open",
106+ NoJavaScriptAccess: false,
107+ TargetDisposition: dto.WindowDispositionNewPopup,
108+ }
109+
110+ assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req))
111+}
112+
113+func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
114+ 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+ }
146+}
147+
148+func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) {
149+ 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+ }
174+}
M internal/ui/coordinator/content/lifecycle.go +2 -0
@@ -58,6 +58,8 @@ func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID)
58 if wv == nil {58 if wv == nil {
59 return59 return
60 }60 }
61+ c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)
62+ c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())
61 c.clearPendingAppearance(paneID)63 c.clearPendingAppearance(paneID)
62 64
63 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),65 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
M internal/ui/coordinator/content/lifecycle_test.go +39 -0
@@ -131,6 +131,7 @@ func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
131 t.Parallel()131 t.Parallel()
132 132
133 wv := mocks.NewMockWebView(t)133 wv := mocks.NewMockWebView(t)
134+ wv.EXPECT().ID().Return(port.WebViewID(11)).Maybe()
134 // idleInhibitor is nil so these won't be called135 // idleInhibitor is nil so these won't be called
135 wv.EXPECT().IsFullscreen().Return(false).Maybe()136 wv.EXPECT().IsFullscreen().Return(false).Maybe()
136 wv.EXPECT().IsPlayingAudio().Return(false).Maybe()137 wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
@@ -148,6 +149,41 @@ func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
148 assert.False(t, exists, "webview should be removed from map after release")149 assert.False(t, exists, "webview should be removed from map after release")
149 }150 }
150 151
152+func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *testing.T) {
153+ t.Parallel()
154+
155+ paneID := entity.PaneID("pane-1")
156+ windowID := "window-1"
157+ wv := mocks.NewMockWebView(t)
158+ wv.EXPECT().ID().Return(port.WebViewID(15)).Maybe()
159+ wv.EXPECT().IsDestroyed().Return(false).Maybe()
160+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
161+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
162+
163+ pool := mocks.NewMockWebViewPool(t)
164+ pool.EXPECT().Release(wv)
165+
166+ c := newMinimalCoordinator()
167+ c.pool = pool
168+ c.popups = newPopupManager()
169+ c.popups.setWindowIDResolver(func(id entity.PaneID) (string, bool) {
170+ if id == paneID {
171+ return windowID, true
172+ }
173+ return "", false
174+ })
175+ c.webViews[paneID] = wv
176+ c.popups.namedContexts.Register(windowID, "named-popup", paneID, wv.ID())
177+
178+ _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
179+ require.True(t, okBefore)
180+
181+ c.ReleaseWebView(context.Background(), paneID)
182+
183+ _, _, okAfter := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
184+ assert.False(t, okAfter)
185+}
186+
151 func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {187 func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
152 t.Parallel()188 t.Parallel()
153 189
@@ -165,6 +201,7 @@ func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
165 t.Parallel()201 t.Parallel()
166 202
167 wv := mocks.NewMockWebView(t)203 wv := mocks.NewMockWebView(t)
204+ wv.EXPECT().ID().Return(port.WebViewID(12)).Maybe()
168 wv.EXPECT().IsFullscreen().Return(true)205 wv.EXPECT().IsFullscreen().Return(true)
169 wv.EXPECT().IsPlayingAudio().Return(false)206 wv.EXPECT().IsPlayingAudio().Return(false)
170 207
@@ -186,6 +223,7 @@ func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
186 t.Parallel()223 t.Parallel()
187 224
188 wv := mocks.NewMockWebView(t)225 wv := mocks.NewMockWebView(t)
226+ wv.EXPECT().ID().Return(port.WebViewID(13)).Maybe()
189 wv.EXPECT().IsFullscreen().Return(false)227 wv.EXPECT().IsFullscreen().Return(false)
190 wv.EXPECT().IsPlayingAudio().Return(true)228 wv.EXPECT().IsPlayingAudio().Return(true)
191 229
@@ -207,6 +245,7 @@ func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
207 t.Parallel()245 t.Parallel()
208 246
209 wv := mocks.NewMockWebView(t)247 wv := mocks.NewMockWebView(t)
248+ wv.EXPECT().ID().Return(port.WebViewID(14)).Maybe()
210 // idleInhibitor is nil so these won't be called249 // idleInhibitor is nil so these won't be called
211 wv.EXPECT().IsFullscreen().Return(false).Maybe()250 wv.EXPECT().IsFullscreen().Return(false).Maybe()
212 wv.EXPECT().IsPlayingAudio().Return(false).Maybe()251 wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
A internal/ui/coordinator/content/named_context_registry.go +123 -0
@@ -0,0 +1,123 @@
1+package content
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+type namedBrowsingContextKey struct {
11+ WindowID string
12+ Name string
13+}
14+
15+type namedBrowsingContextState struct {
16+ PaneID entity.PaneID
17+ WebViewID port.WebViewID
18+}
19+
20+type namedBrowsingContextRegistry struct {
21+ mu sync.RWMutex
22+ contexts map[namedBrowsingContextKey]namedBrowsingContextState
23+}
24+
25+func newNamedBrowsingContextRegistry() *namedBrowsingContextRegistry {
26+ return &namedBrowsingContextRegistry{
27+ contexts: make(map[namedBrowsingContextKey]namedBrowsingContextState),
28+ }
29+}
30+
31+func (r *namedBrowsingContextRegistry) Register(windowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
32+ if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 {
33+ return
34+ }
35+ r.mu.Lock()
36+ r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{
37+ PaneID: paneID,
38+ WebViewID: webViewID,
39+ }
40+ r.mu.Unlock()
41+}
42+
43+func (r *namedBrowsingContextRegistry) Lookup(
44+ windowID, name string,
45+ lookupWebView func(entity.PaneID) port.WebView,
46+ resolveWindowID func(entity.PaneID) (string, bool),
47+) (namedBrowsingContextState, port.WebView, bool) {
48+ if r == nil || windowID == "" || name == "" || lookupWebView == nil || resolveWindowID == nil {
49+ return namedBrowsingContextState{}, nil, false
50+ }
51+
52+ key := namedBrowsingContextKey{WindowID: windowID, Name: name}
53+ r.mu.RLock()
54+ state, ok := r.contexts[key]
55+ r.mu.RUnlock()
56+ if !ok {
57+ return namedBrowsingContextState{}, nil, false
58+ }
59+
60+ currentWindowID, ok := resolveWindowID(state.PaneID)
61+ if !ok || currentWindowID != windowID {
62+ r.deleteIfStateMatches(key, state)
63+ return namedBrowsingContextState{}, nil, false
64+ }
65+
66+ wv := lookupWebView(state.PaneID)
67+ if wv == nil || wv.IsDestroyed() || wv.ID() != state.WebViewID {
68+ r.deleteIfStateMatches(key, state)
69+ return namedBrowsingContextState{}, nil, false
70+ }
71+
72+ return state, wv, true
73+}
74+
75+func (r *namedBrowsingContextRegistry) deleteIfStateMatches(key namedBrowsingContextKey, state namedBrowsingContextState) {
76+ if r == nil {
77+ return
78+ }
79+ r.mu.Lock()
80+ if currentState, ok := r.contexts[key]; ok && currentState == state {
81+ delete(r.contexts, key)
82+ }
83+ r.mu.Unlock()
84+}
85+
86+func (r *namedBrowsingContextRegistry) UnregisterByPaneID(paneID entity.PaneID) {
87+ if r == nil || paneID == "" {
88+ return
89+ }
90+ r.mu.Lock()
91+ for key, state := range r.contexts {
92+ if state.PaneID == paneID {
93+ delete(r.contexts, key)
94+ }
95+ }
96+ r.mu.Unlock()
97+}
98+
99+func (r *namedBrowsingContextRegistry) UnregisterByWebViewID(webViewID port.WebViewID) {
100+ if r == nil || webViewID == 0 {
101+ return
102+ }
103+ r.mu.Lock()
104+ for key, state := range r.contexts {
105+ if state.WebViewID == webViewID {
106+ delete(r.contexts, key)
107+ }
108+ }
109+ r.mu.Unlock()
110+}
111+
112+func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) {
113+ if r == nil || windowID == "" {
114+ return
115+ }
116+ r.mu.Lock()
117+ for key := range r.contexts {
118+ if key.WindowID == windowID {
119+ delete(r.contexts, key)
120+ }
121+ }
122+ r.mu.Unlock()
123+}
A internal/ui/coordinator/content/named_context_registry_test.go +167 -0
@@ -0,0 +1,167 @@
1+package content
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/application/port/mocks"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T) {
13+ t.Parallel()
14+
15+ reg := newNamedBrowsingContextRegistry()
16+ paneID := entity.PaneID("pane-1")
17+ webViewID := port.WebViewID(41)
18+ reg.Register("window-1", "shared-pane", paneID, webViewID)
19+
20+ wv := mocks.NewMockWebView(t)
21+ wv.EXPECT().IsDestroyed().Return(false).Once()
22+ wv.EXPECT().ID().Return(webViewID).Once()
23+
24+ state, gotWV, ok := reg.Lookup(
25+ "window-1",
26+ "shared-pane",
27+ func(gotPaneID entity.PaneID) port.WebView {
28+ assert.Equal(t, paneID, gotPaneID)
29+ return wv
30+ },
31+ func(gotPaneID entity.PaneID) (string, bool) {
32+ assert.Equal(t, paneID, gotPaneID)
33+ return "window-1", true
34+ },
35+ )
36+
37+ assert.True(t, ok)
38+ assert.Equal(t, paneID, state.PaneID)
39+ assert.Same(t, wv, gotWV)
40+}
41+
42+func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) {
43+ t.Parallel()
44+
45+ reg := newNamedBrowsingContextRegistry()
46+ reg.Register("window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41))
47+
48+ _, _, ok := reg.Lookup(
49+ "window-2",
50+ "shared-pane",
51+ func(entity.PaneID) port.WebView { return nil },
52+ func(entity.PaneID) (string, bool) { return "window-2", true },
53+ )
54+
55+ assert.False(t, ok)
56+}
57+
58+func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testing.T) {
59+ t.Parallel()
60+
61+ reg := newNamedBrowsingContextRegistry()
62+ paneID := entity.PaneID("pane-1")
63+ reg.Register("window-1", "shared-pane", paneID, port.WebViewID(41))
64+
65+ _, _, ok := reg.Lookup(
66+ "window-1",
67+ "shared-pane",
68+ func(entity.PaneID) port.WebView { return nil },
69+ func(entity.PaneID) (string, bool) { return "window-2", true },
70+ )
71+ assert.False(t, ok)
72+
73+ _, _, ok = reg.Lookup(
74+ "window-1",
75+ "shared-pane",
76+ func(entity.PaneID) port.WebView { return nil },
77+ func(entity.PaneID) (string, bool) { return "window-1", true },
78+ )
79+ assert.False(t, ok)
80+}
81+
82+func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterWindowMismatch(t *testing.T) {
83+ t.Parallel()
84+
85+ reg := newNamedBrowsingContextRegistry()
86+ oldPaneID := entity.PaneID("pane-old")
87+ newPaneID := entity.PaneID("pane-new")
88+ newWebViewID := port.WebViewID(42)
89+ reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))
90+
91+ _, _, ok := reg.Lookup(
92+ "window-1",
93+ "shared-pane",
94+ func(entity.PaneID) port.WebView { return nil },
95+ func(gotPaneID entity.PaneID) (string, bool) {
96+ assert.Equal(t, oldPaneID, gotPaneID)
97+ reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)
98+ return "window-2", true
99+ },
100+ )
101+ assert.False(t, ok)
102+
103+ wv := mocks.NewMockWebView(t)
104+ wv.EXPECT().IsDestroyed().Return(false).Once()
105+ wv.EXPECT().ID().Return(newWebViewID).Once()
106+
107+ state, gotWV, ok := reg.Lookup(
108+ "window-1",
109+ "shared-pane",
110+ func(gotPaneID entity.PaneID) port.WebView {
111+ assert.Equal(t, newPaneID, gotPaneID)
112+ return wv
113+ },
114+ func(gotPaneID entity.PaneID) (string, bool) {
115+ assert.Equal(t, newPaneID, gotPaneID)
116+ return "window-1", true
117+ },
118+ )
119+ assert.True(t, ok)
120+ assert.Equal(t, newPaneID, state.PaneID)
121+ assert.Same(t, wv, gotWV)
122+}
123+
124+func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterStaleWebView(t *testing.T) {
125+ t.Parallel()
126+
127+ reg := newNamedBrowsingContextRegistry()
128+ oldPaneID := entity.PaneID("pane-old")
129+ newPaneID := entity.PaneID("pane-new")
130+ newWebViewID := port.WebViewID(42)
131+ reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))
132+
133+ _, _, ok := reg.Lookup(
134+ "window-1",
135+ "shared-pane",
136+ func(gotPaneID entity.PaneID) port.WebView {
137+ assert.Equal(t, oldPaneID, gotPaneID)
138+ reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)
139+ return nil
140+ },
141+ func(gotPaneID entity.PaneID) (string, bool) {
142+ assert.Equal(t, oldPaneID, gotPaneID)
143+ return "window-1", true
144+ },
145+ )
146+ assert.False(t, ok)
147+
148+ wv := mocks.NewMockWebView(t)
149+ wv.EXPECT().IsDestroyed().Return(false).Once()
150+ wv.EXPECT().ID().Return(newWebViewID).Once()
151+
152+ state, gotWV, ok := reg.Lookup(
153+ "window-1",
154+ "shared-pane",
155+ func(gotPaneID entity.PaneID) port.WebView {
156+ assert.Equal(t, newPaneID, gotPaneID)
157+ return wv
158+ },
159+ func(gotPaneID entity.PaneID) (string, bool) {
160+ assert.Equal(t, newPaneID, gotPaneID)
161+ return "window-1", true
162+ },
163+ )
164+ assert.True(t, ok)
165+ assert.Equal(t, newPaneID, state.PaneID)
166+ assert.Same(t, wv, gotWV)
167+}
M internal/ui/coordinator/content/oauth.go +1 -30
@@ -221,17 +221,7 @@ func composeOnLoadChanged(existing, next func(port.LoadEvent)) func(port.LoadEve
221 221
222 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.222 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
223 // It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)223 // It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
224-// For providers using postMessage (like Google Sign-In), we rely on the provider calling224+// and a long safety timeout for stuck popups.
225-// window.close() which triggers WebKit's close signal.
226-// A long safety timeout (30s) catches popups that get stuck.
227-func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
228- if wv == nil {
229- return false
230- }
231- opener, ok := wv.(port.PopupOpenerCapable)
232- return ok && opener.HasActivePopupOpenerBridge()
233-}
234-
235 func (c *Coordinator) setupOAuthAutoClose(225 func (c *Coordinator) setupOAuthAutoClose(
236 ctx context.Context,226 ctx context.Context,
237 paneID entity.PaneID,227 paneID entity.PaneID,
@@ -313,11 +303,9 @@ func (c *Coordinator) setupOAuthAutoClose(
313 303
314 // Start safety timer immediately.304 // Start safety timer immediately.
315 startSafetyTimer()305 startSafetyTimer()
316- deferCloseOnNavigation := popupUsesSyntheticOpenerSignals(wv)
317 log.Debug().306 log.Debug().
318 Str("pane", string(paneID)).307 Str("pane", string(paneID)).
319 Uint64("popup_id", uint64(popupID)).308 Uint64("popup_id", uint64(popupID)).
320- Bool("synthetic_opener_active", deferCloseOnNavigation).
321 Msg("oauth auto-close configured")309 Msg("oauth auto-close configured")
322 310
323 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.311 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
@@ -326,25 +314,8 @@ func (c *Coordinator) setupOAuthAutoClose(
326 return314 return
327 }315 }
328 c.capturePopupOAuthState(popupID, uri)316 c.capturePopupOAuthState(popupID, uri)
329- if deferCloseOnNavigation {
330- cancelSafetyTimer()
331- return
332- }
333 requestOAuthClose("navigation")317 requestOAuthClose("navigation")
334 })318 })
335- if opener, ok := wv.(port.PopupOpenerCapable); ok {
336- opener.AddOpenerNavigationCallback(func(uri string) {
337- if !ShouldAutoClose(uri) {
338- return
339- }
340- c.capturePopupOAuthState(popupID, uri)
341- requestOAuthClose("opener-navigation")
342- })
343- opener.AddOpenerMessageCallback(func() {
344- c.capturePopupOAuthMessage(popupID)
345- requestOAuthClose("opener-message")
346- })
347- }
348 319
349 // Cancel safety timer on any close path.320 // Cancel safety timer on any close path.
350 oauthWV.AddCloseCallback(func() {321 oauthWV.AddCloseCallback(func() {
M internal/ui/coordinator/content/oauth_test.go +89 -29
@@ -14,17 +14,6 @@ import (
14 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
15 )15 )
16 16
17-type popupOpenerBridgeStateStub struct {
18- *portmocks.MockWebView
19- active bool
20-}
21-
22-func (s *popupOpenerBridgeStateStub) EnablePopupOpenerBridge(port.WebView, bool) {}
23-func (s *popupOpenerBridgeStateStub) AddOpenerMessageCallback(func()) {}
24-func (s *popupOpenerBridgeStateStub) AddOpenerNavigationCallback(func(string)) {
25-}
26-func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
27-
28 type popupOAuthAutoCloseWebViewStub struct {17 type popupOAuthAutoCloseWebViewStub struct {
29 *portmocks.MockWebView18 *portmocks.MockWebView
30 active bool19 active bool
@@ -32,6 +21,7 @@ type popupOAuthAutoCloseWebViewStub struct {
32 closeCallbacks []func()21 closeCallbacks []func()
33 openerMessageCallbacks []func()22 openerMessageCallbacks []func()
34 openerNavigateCallbacks []func(string)23 openerNavigateCallbacks []func(string)
24+ closeCount int
35 }25 }
36 26
37 func (s *popupOAuthAutoCloseWebViewStub) AddNavigationCallback(fn func(string)) {27 func (s *popupOAuthAutoCloseWebViewStub) AddNavigationCallback(fn func(string)) {
@@ -42,7 +32,7 @@ func (s *popupOAuthAutoCloseWebViewStub) AddCloseCallback(fn func()) {
42 s.closeCallbacks = append(s.closeCallbacks, fn)32 s.closeCallbacks = append(s.closeCallbacks, fn)
43 }33 }
44 34
45-func (*popupOAuthAutoCloseWebViewStub) Close() {}35+func (s *popupOAuthAutoCloseWebViewStub) Close() { s.closeCount++ }
46 36
47 func (*popupOAuthAutoCloseWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}37 func (*popupOAuthAutoCloseWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
48 38
@@ -56,16 +46,6 @@ func (s *popupOAuthAutoCloseWebViewStub) AddOpenerNavigationCallback(fn func(str
56 46
57 func (s *popupOAuthAutoCloseWebViewStub) HasActivePopupOpenerBridge() bool { return s.active }47 func (s *popupOAuthAutoCloseWebViewStub) HasActivePopupOpenerBridge() bool { return s.active }
58 48
59-func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
60- wv := &popupOpenerBridgeStateStub{MockWebView: portmocks.NewMockWebView(t), active: true}
61- assert.True(t, popupUsesSyntheticOpenerSignals(wv))
62-}
63-
64-func TestPopupUsesSyntheticOpenerSignals_RejectsPlainWebView(t *testing.T) {
65- wv := portmocks.NewMockWebView(t)
66- assert.False(t, popupUsesSyntheticOpenerSignals(wv))
67-}
68-
69 func TestComposeOnClose_Order(t *testing.T) {49 func TestComposeOnClose_Order(t *testing.T) {
70 var calls []string50 var calls []string
71 51
@@ -119,19 +99,99 @@ func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
119 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)99 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
120 }100 }
121 101
122-func TestSetupOAuthAutoClose_OpenerNavigationIgnoresNonTerminalURI(t *testing.T) {102+func TestObserveNativePopupAuth_CapturesOAuthStateAndSchedulesParentResumeOnClose(t *testing.T) {
123- popupID := port.WebViewID(1000)103+ popupID := port.WebViewID(999)
124- wv := &popupOAuthAutoCloseWebViewStub{104+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
125- MockWebView: portmocks.NewMockWebView(t),105+ wv.EXPECT().ID().Return(popupID).Once()
126- active: true,106+ wv.EXPECT().IsDestroyed().Return(false).Maybe()
107+
108+ c := &Coordinator{popups: newPopupManager()}
109+ c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
110+ ParentPaneID: entity.PaneID("parent-pane"),
111+ ParentURIAtOpen: "https://x.com/i/flow/login",
112+ PopupWebView: wv,
113+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
114+ })
115+ require.Len(t, wv.navigationCallbacks, 1)
116+ require.Len(t, wv.closeCallbacks, 1)
117+
118+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
119+ state := c.popups.popupOAuth[popupID]
120+ require.NotNil(t, state)
121+ assert.True(t, state.Seen)
122+ assert.True(t, state.Success)
123+ assert.Equal(t, "https://x.com/googlepopupcallback?code=123", state.CallbackURI)
124+
125+ wv.closeCallbacks[0]()
126+ c.popups.mu.RLock()
127+ refreshTimer := c.popups.popupRefresh[entity.PaneID("parent-pane")]
128+ c.popups.mu.RUnlock()
129+ assert.NotNil(t, refreshTimer)
130+}
131+
132+func TestObserveNativePopupAuth_DoesNotTrackWhenWebViewLacksOAuthCallbacks(t *testing.T) {
133+ popupID := port.WebViewID(1001)
134+ wv := portmocks.NewMockWebView(t)
135+ wv.EXPECT().ID().Return(popupID).Once()
136+
137+ c := &Coordinator{popups: newPopupManager()}
138+ c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
139+ ParentPaneID: entity.PaneID("parent-pane"),
140+ ParentURIAtOpen: "https://x.com/i/flow/login",
141+ PopupWebView: wv,
142+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
143+ })
144+
145+ c.popups.mu.RLock()
146+ _, ok := c.popups.popupOAuth[popupID]
147+ c.popups.mu.RUnlock()
148+ assert.False(t, ok)
149+}
150+
151+func TestObserveNativePopupAuth_SchedulesCloseOnlyOnce(t *testing.T) {
152+ popupID := port.WebViewID(1002)
153+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
154+ wv.EXPECT().ID().Return(popupID).Once()
155+ wv.EXPECT().IsDestroyed().Return(false).Once()
156+
157+ oldSchedule := scheduleNativePopupOAuthClose
158+ t.Cleanup(func() {
159+ scheduleNativePopupOAuthClose = oldSchedule
160+ })
161+
162+ scheduledCalls := 0
163+ scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
164+ scheduledCalls++
165+ assert.Equal(t, nativePopupOAuthCloseDelay, delay)
166+ fn()
127 }167 }
168+
169+ c := &Coordinator{popups: newPopupManager()}
170+ c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
171+ ParentPaneID: entity.PaneID("parent-pane"),
172+ ParentURIAtOpen: "https://x.com/i/flow/login",
173+ PopupWebView: wv,
174+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
175+ })
176+ require.Len(t, wv.navigationCallbacks, 1)
177+
178+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
179+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
180+
181+ assert.Equal(t, 1, scheduledCalls)
182+ assert.Equal(t, 1, wv.closeCount)
183+}
184+
185+func TestSetupOAuthAutoClose_NavigationIgnoresNonTerminalURI(t *testing.T) {
186+ popupID := port.WebViewID(1000)
187+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
128 c := &Coordinator{popups: newPopupManager()}188 c := &Coordinator{popups: newPopupManager()}
129 c.trackOAuthPopup(popupID, entity.PaneID("parent-pane"), "https://example.com/start")189 c.trackOAuthPopup(popupID, entity.PaneID("parent-pane"), "https://example.com/start")
130 190
131 c.setupOAuthAutoClose(context.Background(), entity.PaneID("popup-pane"), popupID, wv)191 c.setupOAuthAutoClose(context.Background(), entity.PaneID("popup-pane"), popupID, wv)
132- require.Len(t, wv.openerNavigateCallbacks, 1)192+ require.Len(t, wv.navigationCallbacks, 1)
133 193
134- wv.openerNavigateCallbacks[0]("https://example.com/intermediate")194+ wv.navigationCallbacks[0]("https://example.com/intermediate")
135 195
136 state, ok := c.popups.popupOAuth[popupID]196 state, ok := c.popups.popupOAuth[popupID]
137 require.True(t, ok)197 require.True(t, ok)
M internal/ui/coordinator/content/popup.go +26 -15
@@ -78,19 +78,6 @@ type PendingPopup struct {
78 CreatedAt time.Time78 CreatedAt time.Time
79 }79 }
80 80
81-type namedPopupKey struct {
82- ParentPaneID entity.PaneID
83- FrameName string
84-}
85-
86-type namedPopupState struct {
87- WebView port.WebView
88-}
89-
90-func isReusableNamedPopupFrame(frameName string) bool {
91- return frameName != "" && frameName != "_blank"
92-}
93-
94 // InsertPopupInput contains the data needed to insert a popup into the workspace.81 // InsertPopupInput contains the data needed to insert a popup into the workspace.
95 type InsertPopupInput struct {82 type InsertPopupInput struct {
96 // ParentPaneID is the pane that spawned this popup.83 // ParentPaneID is the pane that spawned this popup.
@@ -116,8 +103,20 @@ type InsertPopupInput struct {
116 TargetURI string103 TargetURI string
117 }104 }
118 105
106+// NativePopupInput contains the data needed to host a native-required popup in
107+// a dedicated top-level GTK window instead of the workspace.
108+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
116+}
117+
119 // GetBehavior returns the appropriate behavior based on popup type and config.118 // GetBehavior returns the appropriate behavior based on popup type and config.
120-func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.PopupBehavior {119+func GetBehavior(popupType PopupType, cfg *entity.BrowsingContextConfig) entity.PopupBehavior {
121 if cfg == nil {120 if cfg == nil {
122 return entity.PopupBehaviorSplit // Default121 return entity.PopupBehaviorSplit // Default
123 }122 }
@@ -146,12 +145,20 @@ func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.Po
146 // SetPopupConfig configures popup handling.145 // SetPopupConfig configures popup handling.
147 func (c *Coordinator) SetPopupConfig(146 func (c *Coordinator) SetPopupConfig(
148 factory port.WebViewFactory,147 factory port.WebViewFactory,
149- popupConfig *entity.PopupBehaviorConfig,148+ popupConfig *entity.BrowsingContextConfig,
150 generateID func() string,149 generateID func() string,
151 ) {150 ) {
152 c.ensurePopupManager().setConfig(factory, popupConfig, generateID)151 c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
153 }152 }
154 153
154+func (c *Coordinator) SetPopupWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
155+ c.ensurePopupManager().setWindowIDResolver(fn)
156+}
157+
158+func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) {
159+ c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID)
160+}
161+
155 // SetOnInsertPopup sets the callback to insert popups into the workspace.162 // SetOnInsertPopup sets the callback to insert popups into the workspace.
156 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {163 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
157 c.ensurePopupManager().setOnInsertPopup(fn)164 c.ensurePopupManager().setOnInsertPopup(fn)
@@ -162,6 +169,10 @@ func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.
162 c.ensurePopupManager().setOnClosePane(fn)169 c.ensurePopupManager().setOnClosePane(fn)
163 }170 }
164 171
172+func (c *Coordinator) SetOnOpenNativePopup(fn func(ctx context.Context, input NativePopupInput) error) {
173+ c.ensurePopupManager().setOnOpenNativePopup(fn)
174+}
175+
165 // buildPopupCreateHandler returns the OnCreate callback for a WebView.176 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
166 // Returns nil if popup handling is not configured.177 // Returns nil if popup handling is not configured.
167 func (c *Coordinator) buildPopupCreateHandler(178 func (c *Coordinator) buildPopupCreateHandler(
M internal/ui/coordinator/content/popup_manager.go +230 -178
@@ -2,15 +2,14 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "strings"6 "strings"
8 "sync"7 "sync"
9 "time"8 "time"
10 9
10+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13- domainerrors "github.com/bnema/dumber/internal/domain/errors"
14 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
15 )14 )
16 15
@@ -18,18 +17,19 @@ import (
18 // adapter layer so popup pane bookkeeping and workspace orchestration do not17 // adapter layer so popup pane bookkeeping and workspace orchestration do not
19 // leak into the application/usecase or domain layers.18 // leak into the application/usecase or domain layers.
20 type popupManager struct {19 type popupManager struct {
21- factory port.WebViewFactory20+ factory port.WebViewFactory
22- popupConfig *entity.PopupBehaviorConfig21+ popupConfig *entity.BrowsingContextConfig
23- onInsertPopup func(ctx context.Context, input InsertPopupInput) error22+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24- onClosePane func(ctx context.Context, paneID entity.PaneID) error23+ onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
25- generatePaneID func() string24+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
26- pendingPopups map[port.WebViewID]*PendingPopup25+ generatePaneID func() string
27- namedPopups map[namedPopupKey]*namedPopupState26+ windowIDForPane func(entity.PaneID) (string, bool)
28- popupOAuth map[port.WebViewID]*popupOAuthState27+ policy browsingContextPolicy
29- popupRefresh map[entity.PaneID]*time.Timer28+ namedContexts *namedBrowsingContextRegistry
30- relatedPopupUnsupported bool29+ pendingPopups map[port.WebViewID]*PendingPopup
31- relatedPopupSupportDetected bool30+ popupOAuth map[port.WebViewID]*popupOAuthState
32- mu sync.RWMutex31+ popupRefresh map[entity.PaneID]*time.Timer
32+ mu sync.RWMutex
33 }33 }
34 34
35 type popupOAuthState struct {35 type popupOAuthState struct {
@@ -82,8 +82,8 @@ func (pm *popupManager) ensureInitialized() {
82 if pm.pendingPopups == nil {82 if pm.pendingPopups == nil {
83 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)83 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
84 }84 }
85- if pm.namedPopups == nil {85+ if pm.namedContexts == nil {
86- pm.namedPopups = make(map[namedPopupKey]*namedPopupState)86+ pm.namedContexts = newNamedBrowsingContextRegistry()
87 }87 }
88 if pm.popupOAuth == nil {88 if pm.popupOAuth == nil {
89 pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)89 pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
@@ -95,7 +95,7 @@ func (pm *popupManager) ensureInitialized() {
95 95
96 func (pm *popupManager) setConfig(96 func (pm *popupManager) setConfig(
97 factory port.WebViewFactory,97 factory port.WebViewFactory,
98- popupConfig *entity.PopupBehaviorConfig,98+ popupConfig *entity.BrowsingContextConfig,
99 generateID func() string,99 generateID func() string,
100 ) {100 ) {
101 if pm == nil {101 if pm == nil {
@@ -105,11 +105,28 @@ func (pm *popupManager) setConfig(
105 pm.factory = factory105 pm.factory = factory
106 pm.popupConfig = popupConfig106 pm.popupConfig = popupConfig
107 pm.generatePaneID = generateID107 pm.generatePaneID = generateID
108+}
108 109
109- pm.mu.Lock()110+func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
110- pm.relatedPopupUnsupported = false111+ if pm == nil {
111- pm.relatedPopupSupportDetected = false112+ return
112- pm.mu.Unlock()113+ }
114+ pm.windowIDForPane = fn
115+}
116+
117+func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
118+ behavior := GetBehavior(PopupTypeTab, cfg)
119+ placement := "right"
120+ if cfg != nil {
121+ placement = cfg.Placement
122+ }
123+ return behavior, placement
124+}
125+
126+func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision dto.HostDecision) {
127+ if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {
128+ carrier.SetBrowsingContextHostDecision(decision)
129+ }
113 }130 }
114 131
115 func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {132 func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
@@ -126,6 +143,13 @@ func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entit
126 pm.onClosePane = fn143 pm.onClosePane = fn
127 }144 }
128 145
146+func (pm *popupManager) setOnOpenNativePopup(fn func(ctx context.Context, input NativePopupInput) error) {
147+ if pm == nil {
148+ return
149+ }
150+ pm.onOpenNativePopup = fn
151+}
152+
129 func (pm *popupManager) createPopupPane(153 func (pm *popupManager) createPopupPane(
130 popupID port.WebViewID,154 popupID port.WebViewID,
131 parentPaneID entity.PaneID,155 parentPaneID entity.PaneID,
@@ -147,70 +171,45 @@ func (pm *popupManager) createPopupPane(
147 return paneID, popupPane171 return paneID, popupPane
148 }172 }
149 173
150-func (pm *popupManager) relatedPopupSupportDisabled() bool {174+func (pm *popupManager) lookupReusableNamedPopup(
151- if pm == nil {175+ parentPaneID entity.PaneID,
152- return false176+ frameName string,
153- }177+ hooks popupCoordinatorHooks,
154- pm.mu.RLock()178+) (port.WebView, bool) {
155- defer pm.mu.RUnlock()179+ if pm == nil || pm.namedContexts == nil {
156- return pm.relatedPopupSupportDetected && pm.relatedPopupUnsupported180+ return nil, false
157-}
158-
159-func (pm *popupManager) markRelatedPopupUnsupported() {
160- if pm == nil {
161- return
162- }
163- pm.mu.Lock()
164- defer pm.mu.Unlock()
165- pm.relatedPopupSupportDetected = true
166- pm.relatedPopupUnsupported = true
167-}
168-
169-func (pm *popupManager) markRelatedPopupSupported() {
170- if pm == nil {
171- return
172 }181 }
173- pm.mu.Lock()
174- defer pm.mu.Unlock()
175- pm.relatedPopupSupportDetected = true
176- pm.relatedPopupUnsupported = false
177-}
178 182
179-func (pm *popupManager) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {183+ name := reusableBrowsingContextName(frameName)
180- if pm == nil || !isReusableNamedPopupFrame(frameName) {184+ if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
181 return nil, false185 return nil, false
182 }186 }
183 187
184- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}188+ windowID, ok := pm.windowIDForPane(parentPaneID)
185-189+ if !ok || windowID == "" {
186- pm.mu.RLock()
187- state, ok := pm.namedPopups[key]
188- pm.mu.RUnlock()
189- if !ok || state == nil || state.WebView == nil {
190 return nil, false190 return nil, false
191 }191 }
192 192
193- if state.WebView.IsDestroyed() {193+ _, wv, ok := pm.namedContexts.Lookup(windowID, name, hooks.getWebView, pm.windowIDForPane)
194- pm.mu.Lock()194+ if !ok {
195- if current, ok := pm.namedPopups[key]; ok && current == state {
196- delete(pm.namedPopups, key)
197- }
198- pm.mu.Unlock()
199 return nil, false195 return nil, false
200 }196 }
201-197+ return wv, true
202- return state, true
203 }198 }
204 199
205-func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, wv port.WebView) {200+func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
206- if pm == nil || !isReusableNamedPopupFrame(frameName) || wv == nil {201+ if pm == nil || pm.namedContexts == nil || wv == nil {
207 return202 return
208 }203 }
209-204+ name := reusableBrowsingContextName(frameName)
210- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}205+ if name == "" || pm.windowIDForPane == nil {
211- pm.mu.Lock()206+ return
212- pm.namedPopups[key] = &namedPopupState{WebView: wv}207+ }
213- pm.mu.Unlock()208+ windowID, ok := pm.windowIDForPane(parentPaneID)
209+ if !ok || windowID == "" {
210+ return
211+ }
212+ pm.namedContexts.Register(windowID, name, paneID, wv.ID())
214 }213 }
215 214
216 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {215 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
@@ -225,16 +224,24 @@ func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetU
225 }224 }
226 225
227 func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {226 func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
228- if pm == nil {227+ if pm == nil || pm.namedContexts == nil {
229 return228 return
230 }229 }
231- pm.mu.Lock()230+ pm.namedContexts.UnregisterByWebViewID(popupID)
232- for key, state := range pm.namedPopups {231+}
233- if state != nil && state.WebView != nil && state.WebView.ID() == popupID {232+
234- delete(pm.namedPopups, key)233+func (pm *popupManager) clearReusableNamedPopupByPaneID(paneID entity.PaneID) {
235- }234+ if pm == nil || pm.namedContexts == nil {
235+ return
236 }236 }
237- pm.mu.Unlock()237+ pm.namedContexts.UnregisterByPaneID(paneID)
238+}
239+
240+func (pm *popupManager) clearReusableNamedPopupsForWindow(windowID string) {
241+ if pm == nil || pm.namedContexts == nil {
242+ return
243+ }
244+ pm.namedContexts.UnregisterWindow(windowID)
238 }245 }
239 246
240 func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {247 func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
@@ -347,96 +354,109 @@ func (pm *popupManager) createPopupWebView(
347 ctx context.Context,354 ctx context.Context,
348 parentID port.WebViewID,355 parentID port.WebViewID,
349 targetURI string,356 targetURI string,
350- noJavaScriptAccess bool,357+ preparePaneHosted bool,
351-) (port.WebView, bool, error) {358+) (port.WebView, error) {
352 if pm == nil || pm.factory == nil {359 if pm == nil || pm.factory == nil {
353- return nil, false, fmt.Errorf("no webview factory configured")360+ return nil, fmt.Errorf("no webview factory configured")
354 }361 }
355 362
356- log := logging.FromContext(ctx)363+ popupWV, err := pm.factory.CreateRelated(ctx, parentID)
357- var relatedErr error364+ if err != nil {
358-365+ return nil, fmt.Errorf("create related popup webview for %s: %w", logging.TruncateURL(targetURI, logURLMaxLen), err)
359- if !noJavaScriptAccess && !pm.relatedPopupSupportDisabled() {
360- popupWV, err := pm.factory.CreateRelated(ctx, parentID)
361- if err == nil && popupWV != nil {
362- pm.markRelatedPopupSupported()
363- return popupWV, false, nil
364- }
365- if err == nil {
366- relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
367- log.Warn().
368- Uint64("parent_webview_id", uint64(parentID)).
369- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
370- Msg("related popup webview factory returned nil popup, falling back to regular webview")
371- } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
372- relatedErr = err
373- pm.markRelatedPopupUnsupported()
374- log.Debug().
375- Err(err).
376- Uint64("parent_webview_id", uint64(parentID)).
377- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
378- Msg("related popup webview unavailable, falling back to regular webview")
379- } else {
380- relatedErr = err
381- log.Warn().
382- Err(err).
383- Uint64("parent_webview_id", uint64(parentID)).
384- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
385- Msg("related popup webview creation failed, falling back to regular webview")
386- }
387- } else if noJavaScriptAccess {
388- log.Debug().
389- Uint64("parent_webview_id", uint64(parentID)).
390- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
391- Msg("popup requested no JavaScript opener access, creating regular webview")
392- } else {
393- log.Debug().
394- Uint64("parent_webview_id", uint64(parentID)).
395- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
396- Msg("related popup webviews known unsupported, creating regular webview")
397- }
398-
399- popupWV, fallbackErr := pm.factory.Create(ctx)
400- if fallbackErr != nil {
401- if relatedErr != nil {
402- return nil, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
403- }
404- return nil, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
405 }366 }
406 if popupWV == nil {367 if popupWV == nil {
407- if relatedErr != nil {368+ return nil, fmt.Errorf("related popup webview factory returned nil for %s", logging.TruncateURL(targetURI, logURLMaxLen))
408- return nil, false, fmt.Errorf("create popup webview: related failed: %w; fallback returned nil", relatedErr)369+ }
370+ if preparePaneHosted {
371+ if paneHosted, ok := popupWV.(port.PaneHostedBrowsingContextCapable); ok {
372+ paneHosted.PreparePaneHostedBrowsingContext()
409 }373 }
410- return nil, false, fmt.Errorf("create popup webview: fallback returned nil")
411 }374 }
412-375+ return popupWV, nil
413- return popupWV, true, nil
414 }376 }
415 377
416 func (pm *popupManager) reuseNamedPopup(378 func (pm *popupManager) reuseNamedPopup(
417 ctx context.Context,379 ctx context.Context,
380+ hooks popupCoordinatorHooks,
418 parentPaneID entity.PaneID,381 parentPaneID entity.PaneID,
419 frameName string,382 frameName string,
420 targetURI string,383 targetURI string,
421 ) (port.WebView, bool) {384 ) (port.WebView, bool) {
422 log := logging.FromContext(ctx)385 log := logging.FromContext(ctx)
423 386
424- if existing, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName); ok {387+ existingWV, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName, hooks)
425- pm.updatePendingPopupTarget(existing.WebView.ID(), targetURI)388+ if !ok {
426- if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {389+ return nil, false
427- log.Warn().Err(err).390+ }
428- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).391+
429- Msg("failed to load target URI in reused popup")392+ pm.updatePendingPopupTarget(existingWV.ID(), targetURI)
430- }393+ if err := existingWV.LoadURI(ctx, targetURI); err != nil {
431- log.Info().394+ log.Warn().Err(err).
432- Str("parent_pane", string(parentPaneID)).
433- Str("frame_name", frameName).
434 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).395 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
435- Msg("reused named popup")396+ Msg("failed to load target URI in reused popup")
436- return existing.WebView, true397+ }
398+ log.Info().
399+ Str("parent_pane", string(parentPaneID)).
400+ Str("frame_name", frameName).
401+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
402+ Msg("reused named popup")
403+ return existingWV, true
404+}
405+
406+func (pm *popupManager) popupParentURIAtOpen(
407+ parentPaneID entity.PaneID,
408+ parentWV port.WebView,
409+ hooks popupCoordinatorHooks,
410+ targetURI string,
411+) string {
412+ if pm.popupConfig == nil || !pm.popupConfig.OAuthAutoClose || !IsOAuthURL(targetURI) {
413+ return ""
414+ }
415+ parentURIAtOpen := parentWV.URI()
416+ if parentURIAtOpen == "" && hooks.getWebView != nil {
417+ if parent := hooks.getWebView(parentPaneID); parent != nil {
418+ parentURIAtOpen = parent.URI()
419+ }
437 }420 }
421+ return parentURIAtOpen
422+}
438 423
439- return nil, false424+func (pm *popupManager) openNativePopup(
425+ ctx context.Context,
426+ parentPaneID entity.PaneID,
427+ parentID port.WebViewID,
428+ parentURIAtOpen string,
429+ req port.PopupRequest,
430+ decision dto.HostDecision,
431+) port.WebView {
432+ log := logging.FromContext(ctx)
433+ if pm.onOpenNativePopup == nil {
434+ log.Warn().Msg("native popup host callback not configured")
435+ return nil
436+ }
437+ popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
438+ if err != nil {
439+ log.Error().Err(err).Msg("failed to create webview for native popup")
440+ return nil
441+ }
442+ pm.setBrowsingContextDecision(popupWV, decision)
443+ if err := pm.onOpenNativePopup(ctx, NativePopupInput{
444+ ParentPaneID: parentPaneID,
445+ ParentWebViewID: parentID,
446+ ParentURIAtOpen: parentURIAtOpen,
447+ PopupWebView: popupWV,
448+ TargetURI: req.TargetURI,
449+ Request: req,
450+ ObserveOAuthAutoClose: pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI),
451+ }); err != nil {
452+ log.Error().Err(err).Msg("failed to open native popup host")
453+ popupWV.Destroy()
454+ return nil
455+ }
456+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
457+ lifecycle.PrimePopupNavigation(req.TargetURI)
458+ }
459+ return popupWV
440 }460 }
441 461
442 func (pm *popupManager) handlePopupCreate(462 func (pm *popupManager) handlePopupCreate(
@@ -466,31 +486,51 @@ func (pm *popupManager) handlePopupCreate(
466 }486 }
467 487
468 parentID := parentWV.ID()488 parentID := parentWV.ID()
469- parentURIAtOpen := ""489+ parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
470- if pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {490+
471- parentURIAtOpen = parentWV.URI()491+ request := buildPopupBrowsingContextRequest(req)
472- if parentURIAtOpen == "" && hooks.getWebView != nil {492+ namedContextExists := false
473- if parent := hooks.getWebView(parentPaneID); parent != nil {
474- parentURIAtOpen = parent.URI()
475- }
476- }
477- }
478 if !req.NoJavaScriptAccess {493 if !req.NoJavaScriptAccess {
479- if reused, ok := pm.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {494+ _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
480- return reused
481- }
482 }495 }
496+ decision := pm.policy.Decide(request, namedContextExists)
497+ log.Debug().
498+ Str("decision", string(decision.Kind)).
499+ Str("reason", decision.Reason).
500+ Str("context_name", decision.BrowsingContextName).
501+ Msg("browsing context host decision")
483 502
484- popupWV, usedRegularFallback, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)503+ switch decision.Kind {
504+ case dto.HostDecisionDeny:
505+ return nil
506+ case dto.HostDecisionReuseNamedPane:
507+ if req.NoJavaScriptAccess {
508+ log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")
509+ return nil
510+ }
511+ reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI)
512+ if ok {
513+ pm.setBrowsingContextDecision(reused, decision)
514+ return reused
515+ }
516+ log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
517+ decision.Kind = dto.HostDecisionCreatePane
518+ decision.ReuseContextName = ""
519+ decision.Reason = "named browsing context unavailable; creating replacement pane"
520+ case dto.HostDecisionCreateNativeWin:
521+ return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
522+ case dto.HostDecisionCreatePane:
523+ // Continue below.
524+ break
525+ }
526+
527+ popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, true)
485 if err != nil {528 if err != nil {
486 log.Error().Err(err).Msg("failed to create webview for popup")529 log.Error().Err(err).Msg("failed to create webview for popup")
487 return nil530 return nil
488 }531 }
489- if usedRegularFallback {532+
490- if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {533+ pm.setBrowsingContextDecision(popupWV, decision)
491- openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
492- }
493- }
494 534
495 popupType := DetectPopupType(req.FrameName)535 popupType := DetectPopupType(req.FrameName)
496 popupID := popupWV.ID()536 popupID := popupWV.ID()
@@ -574,7 +614,7 @@ func (pm *popupManager) finishPopupCreate(
574 lifecycle.PrimePopupNavigation(create.Request.TargetURI)614 lifecycle.PrimePopupNavigation(create.Request.TargetURI)
575 }615 }
576 if !create.Request.NoJavaScriptAccess {616 if !create.Request.NoJavaScriptAccess {
577- pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)617+ pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupPaneID, create.PopupWebView)
578 }618 }
579 if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {619 if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
580 if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {620 if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
@@ -673,8 +713,7 @@ func (pm *popupManager) logPopupCloseSignal(ctx context.Context, hooks popupCoor
673 if wv := hooks.getWebView(paneID); wv != nil {713 if wv := hooks.getWebView(paneID); wv != nil {
674 fields = fields.714 fields = fields.
675 Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).715 Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).
676- Bool("is_loading", wv.IsLoading()).716+ Bool("is_loading", wv.IsLoading())
677- Bool("synthetic_opener_active", popupUsesSyntheticOpenerSignals(wv))
678 }717 }
679 }718 }
680 }719 }
@@ -761,12 +800,32 @@ func (pm *popupManager) handleLinkMiddleClick(
761 log.Warn().Msg("no webview factory, cannot handle middle-click")800 log.Warn().Msg("no webview factory, cannot handle middle-click")
762 return false801 return false
763 }802 }
803+ if hooks.getWebView == nil {
804+ log.Warn().Msg("no parent webview lookup available for middle-click browsing context")
805+ return false
806+ }
807+ parentWV := hooks.getWebView(parentPaneID)
808+ if parentWV == nil {
809+ log.Warn().Str("parent_pane", string(parentPaneID)).Msg("parent webview not found for middle-click browsing context")
810+ return false
811+ }
764 812
765- newWV, err := pm.factory.Create(ctx)813+ decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false)
814+ log.Debug().
815+ Str("decision", string(decision.Kind)).
816+ Str("reason", decision.Reason).
817+ Msg("middle-click browsing context host decision")
818+ if decision.Kind != dto.HostDecisionCreatePane {
819+ log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
820+ return false
821+ }
822+
823+ newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true)
766 if err != nil {824 if err != nil {
767 log.Error().Err(err).Msg("failed to create webview for middle-click")825 log.Error().Err(err).Msg("failed to create webview for middle-click")
768 return false826 return false
769 }827 }
828+ pm.setBrowsingContextDecision(newWV, decision)
770 829
771 var paneID entity.PaneID830 var paneID entity.PaneID
772 if pm.generatePaneID != nil {831 if pm.generatePaneID != nil {
@@ -786,14 +845,7 @@ func (pm *popupManager) handleLinkMiddleClick(
786 hooks.setupWebViewCallbacks(ctx, paneID, newWV)845 hooks.setupWebViewCallbacks(ctx, paneID, newWV)
787 }846 }
788 847
789- behavior := entity.PopupBehaviorStacked848+ behavior, placement := popupTabInsertionConfig(pm.popupConfig)
790- if pm.popupConfig != nil && pm.popupConfig.BlankTargetBehavior != "" {
791- behavior = entity.PopupBehavior(pm.popupConfig.BlankTargetBehavior)
792- }
793- placement := "right"
794- if pm.popupConfig != nil {
795- placement = pm.popupConfig.Placement
796- }
797 849
798 if pm.onInsertPopup != nil {850 if pm.onInsertPopup != nil {
799 popupInput := InsertPopupInput{851 popupInput := InsertPopupInput{
M internal/ui/coordinator/content/popup_test.go +228 -118
@@ -10,6 +10,7 @@ import (
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13+ "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/port/mocks"15 "github.com/bnema/dumber/internal/application/port/mocks"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
@@ -75,35 +76,35 @@ func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
75 t.Parallel()76 t.Parallel()
76 77
77 // BlankTargetBehavior is empty → falls through to default "stacked"78 // BlankTargetBehavior is empty → falls through to default "stacked"
78- cfg := &entity.PopupBehaviorConfig{}79+ cfg := &entity.BrowsingContextConfig{}
79 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))80 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
80 }81 }
81 82
82 func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {83 func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
83 t.Parallel()84 t.Parallel()
84 85
85- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "split"}86+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "split"}
86 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))87 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
87 }88 }
88 89
89 func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {90 func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
90 t.Parallel()91 t.Parallel()
91 92
92- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}93+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "stacked"}
93 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))94 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
94 }95 }
95 96
96 func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {97 func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
97 t.Parallel()98 t.Parallel()
98 99
99- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}100+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "tabbed"}
100 assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))101 assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
101 }102 }
102 103
103 func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {104 func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
104 t.Parallel()105 t.Parallel()
105 106
106- cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorWindowed}107+ cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorWindowed}
107 assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))108 assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
108 }109 }
109 110
@@ -111,7 +112,7 @@ func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
111 t.Parallel()112 t.Parallel()
112 113
113 // Behavior zero-value is empty string; GetBehavior returns it as-is.114 // Behavior zero-value is empty string; GetBehavior returns it as-is.
114- cfg := &entity.PopupBehaviorConfig{}115+ cfg := &entity.BrowsingContextConfig{}
115 assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))116 assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
116 }117 }
117 118
@@ -120,7 +121,7 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
120 121
121 // GetBehavior itself does not honor OpenInNewPane — that is enforced by122 // GetBehavior itself does not honor OpenInNewPane — that is enforced by
122 // handlePopupCreate. GetBehavior still returns the configured value.123 // handlePopupCreate. GetBehavior still returns the configured value.
123- cfg := &entity.PopupBehaviorConfig{124+ cfg := &entity.BrowsingContextConfig{
124 OpenInNewPane: false,125 OpenInNewPane: false,
125 BlankTargetBehavior: "split",126 BlankTargetBehavior: "split",
126 }127 }
@@ -139,7 +140,7 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
139 popupWV.EXPECT().ID().Return(popupID).Once()140 popupWV.EXPECT().ID().Return(popupID).Once()
140 141
141 factory := mocks.NewMockWebViewFactory(t)142 factory := mocks.NewMockWebViewFactory(t)
142- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()143+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
143 144
144 c := &Coordinator{145 c := &Coordinator{
145 webViews: make(map[entity.PaneID]port.WebView),146 webViews: make(map[entity.PaneID]port.WebView),
@@ -162,7 +163,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
162 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163 164
164 factory := mocks.NewMockWebViewFactory(t)165 factory := mocks.NewMockWebViewFactory(t)
165- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()166+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
166 167
167 c := &Coordinator{168 c := &Coordinator{
168 webViews: make(map[entity.PaneID]port.WebView),169 webViews: make(map[entity.PaneID]port.WebView),
@@ -230,7 +231,7 @@ func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t
230 require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))231 require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
231 }232 }
232 233
233-func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {234+func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
234 ctx := context.Background()235 ctx := context.Background()
235 parentPaneID := entity.PaneID("parent-pane")236 parentPaneID := entity.PaneID("parent-pane")
236 parentWV := mocks.NewMockWebView(t)237 parentWV := mocks.NewMockWebView(t)
@@ -245,7 +246,7 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
245 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()246 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
246 247
247 factory := mocks.NewMockWebViewFactory(t)248 factory := mocks.NewMockWebViewFactory(t)
248- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()249+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
249 250
250 c := &Coordinator{251 c := &Coordinator{
251 webViews: make(map[entity.PaneID]port.WebView),252 webViews: make(map[entity.PaneID]port.WebView),
@@ -267,74 +268,44 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
267 require.Same(t, popupWV, created)268 require.Same(t, popupWV, created)
268 }269 }
269 270
270-func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *testing.T) {271+func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) {
271 ctx := context.Background()272 ctx := context.Background()
272 parentPaneID := entity.PaneID("parent-pane")273 parentPaneID := entity.PaneID("parent-pane")
273 parentWV := mocks.NewMockWebView(t)274 parentWV := mocks.NewMockWebView(t)
274- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()275+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
275-
276- firstPopupWV := mocks.NewMockWebView(t)
277- firstPopupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
278- firstPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
279- firstPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
280- firstPopupWV.EXPECT().IsLoading().Return(false).Once()
281- firstPopupWV.EXPECT().URI().Return("").Once()
282- firstPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Once()
283-
284- secondPopupWV := mocks.NewMockWebView(t)
285- secondPopupWV.EXPECT().ID().Return(port.WebViewID(203)).Once()
286- secondPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
287- secondPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
288- secondPopupWV.EXPECT().IsLoading().Return(false).Once()
289- secondPopupWV.EXPECT().URI().Return("").Once()
290- secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
291 276
292 factory := mocks.NewMockWebViewFactory(t)277 factory := mocks.NewMockWebViewFactory(t)
293 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()278 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
294- factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
295- factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
296 279
280+ insertCalls := 0
297 c := &Coordinator{281 c := &Coordinator{
298 webViews: make(map[entity.PaneID]port.WebView),282 webViews: make(map[entity.PaneID]port.WebView),
299 popups: newPopupManager(),283 popups: newPopupManager(),
300 }284 }
301 c.SetPopupConfig(factory, nil, nil)285 c.SetPopupConfig(factory, nil, nil)
302 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {286 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
287+ insertCalls++
303 return nil288 return nil
304 })289 })
305 290
306- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{291+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
307- TargetURI: "https://example.com/first",292+ TargetURI: "https://example.com/edit",
308- FrameName: "_blank",
309- IsUserGesture: true,
310- })
311- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
312- TargetURI: "https://example.com/second",
313 FrameName: "_blank",293 FrameName: "_blank",
314 IsUserGesture: true,294 IsUserGesture: true,
315 })295 })
316 296
317- require.Same(t, firstPopupWV, first)297+ require.Nil(t, created)
318- require.Same(t, secondPopupWV, second)298+ assert.Equal(t, 0, insertCalls)
319 }299 }
320 300
321-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupported(t *testing.T) {301+func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
322 ctx := context.Background()302 ctx := context.Background()
323 parentPaneID := entity.PaneID("parent-pane")303 parentPaneID := entity.PaneID("parent-pane")
324 parentWV := mocks.NewMockWebView(t)304 parentWV := mocks.NewMockWebView(t)
325 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()305 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
326 306
327- popupWV := mocks.NewMockWebView(t)
328- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
329- popupWV.EXPECT().Generation().Return(uint64(1)).Once()
330- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
331- popupWV.EXPECT().IsLoading().Return(false).Once()
332- popupWV.EXPECT().URI().Return("").Once()
333- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
334-
335 factory := mocks.NewMockWebViewFactory(t)307 factory := mocks.NewMockWebViewFactory(t)
336- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()308+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
337- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
338 309
339 insertCalls := 0310 insertCalls := 0
340 c := &Coordinator{311 c := &Coordinator{
@@ -344,9 +315,6 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
344 c.SetPopupConfig(factory, nil, nil)315 c.SetPopupConfig(factory, nil, nil)
345 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {316 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
346 insertCalls++317 insertCalls++
347- assert.Equal(t, parentPaneID, input.ParentPaneID)
348- assert.Equal(t, popupWV, input.WebView)
349- assert.Equal(t, "https://example.com/edit", input.TargetURI)
350 return nil318 return nil
351 })319 })
352 320
@@ -356,90 +324,186 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
356 IsUserGesture: true,324 IsUserGesture: true,
357 })325 })
358 326
359- require.Same(t, popupWV, created)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)
360 assert.Equal(t, 1, insertCalls)362 assert.Equal(t, 1, insertCalls)
363+ decision, hasDecision := newWV.BrowsingContextHostDecision()
364+ require.True(t, hasDecision)
365+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
361 }366 }
362 367
363-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpectedly(t *testing.T) {368+func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
364 ctx := context.Background()369 ctx := context.Background()
365 parentPaneID := entity.PaneID("parent-pane")370 parentPaneID := entity.PaneID("parent-pane")
366 parentWV := mocks.NewMockWebView(t)371 parentWV := mocks.NewMockWebView(t)
367 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()372 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
373+ parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once()
368 374
369- popupWV := mocks.NewMockWebView(t)375+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
370- popupWV.EXPECT().ID().Return(port.WebViewID(204)).Once()376+ popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe()
371- popupWV.EXPECT().Generation().Return(uint64(1)).Once()
372- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
373- popupWV.EXPECT().IsLoading().Return(false).Once()
374- popupWV.EXPECT().URI().Return("").Once()
375- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
376 377
377 factory := mocks.NewMockWebViewFactory(t)378 factory := mocks.NewMockWebViewFactory(t)
378- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()379+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
379- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
380 380
381 insertCalls := 0381 insertCalls := 0
382+ nativeCalls := 0
382 c := &Coordinator{383 c := &Coordinator{
383 webViews: make(map[entity.PaneID]port.WebView),384 webViews: make(map[entity.PaneID]port.WebView),
384 popups: newPopupManager(),385 popups: newPopupManager(),
385 }386 }
386- c.SetPopupConfig(factory, nil, nil)387+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil)
387- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {388+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
388 insertCalls++389 insertCalls++
389- assert.Equal(t, popupWV, input.WebView)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)
390 return nil398 return nil
391 })399 })
392 400
393 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{401 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
394- TargetURI: "https://example.com/edit",402+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
395- FrameName: "_blank",403+ FrameName: "oauth-popup",
396 IsUserGesture: true,404 IsUserGesture: true,
397 })405 })
398 406
399- require.Same(t, popupWV, created)407+ require.Equal(t, popupWV, created)
400- assert.Equal(t, 1, insertCalls)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)
401 }411 }
402 412
403-func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported(t *testing.T) {413+func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) {
404 ctx := context.Background()414 ctx := context.Background()
405 parentPaneID := entity.PaneID("parent-pane")415 parentPaneID := entity.PaneID("parent-pane")
406 parentWV := mocks.NewMockWebView(t)416 parentWV := mocks.NewMockWebView(t)
407 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()417 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
408 418
409- popupWV := &popupOpenerBridgeWebViewStub{MockWebView: mocks.NewMockWebView(t)}419+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
410- popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()420+ popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe()
411- popupWV.EXPECT().Generation().Return(uint64(1)).Once()
412- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
413- popupWV.EXPECT().IsLoading().Return(false).Once()
414- popupWV.EXPECT().URI().Return("").Once()
415- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup?redirect=%2Fcallback").Return(nil).Once()
416 421
417 factory := mocks.NewMockWebViewFactory(t)422 factory := mocks.NewMockWebViewFactory(t)
418- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()423+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
419- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
420 424
421 insertCalls := 0425 insertCalls := 0
426+ nativeCalls := 0
422 c := &Coordinator{427 c := &Coordinator{
423 webViews: make(map[entity.PaneID]port.WebView),428 webViews: make(map[entity.PaneID]port.WebView),
424 popups: newPopupManager(),429 popups: newPopupManager(),
425 }430 }
426- c.SetPopupConfig(factory, nil, nil)431+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
427- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {432+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
428 insertCalls++433 insertCalls++
429- assert.Equal(t, "https://example.com/popup?redirect=%2Fcallback", input.TargetURI)434+ return nil
435+ })
436+ c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
437+ nativeCalls++
438+ assert.False(t, input.ObserveOAuthAutoClose)
430 return nil439 return nil
431 })440 })
432 441
433 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{442 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
434- TargetURI: "https://example.com/popup?redirect=%2Fcallback",443+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
435- FrameName: "auth-popup",444+ FrameName: "oauth-popup",
436 IsUserGesture: true,445 IsUserGesture: true,
437 })446 })
438 447
439- require.Same(t, popupWV, created)448+ require.Equal(t, popupWV, created)
440- assert.Equal(t, 1, insertCalls)449+ assert.Equal(t, 1, nativeCalls)
441- assert.Same(t, parentWV, popupWV.parent)450+ assert.Equal(t, 0, insertCalls)
442- assert.False(t, popupWV.noJavaScriptAccess)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)
443 }507 }
444 508
445 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {509 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
@@ -449,19 +513,19 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
449 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()513 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
450 514
451 popupWV := mocks.NewMockWebView(t)515 popupWV := mocks.NewMockWebView(t)
452- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Twice()516+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
453 popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()517 popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
454 popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()518 popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
455 popupWV.EXPECT().IsLoading().Return(false).Maybe()519 popupWV.EXPECT().IsLoading().Return(false).Maybe()
456 currentURI := ""520 currentURI := ""
457 popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()521 popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
458 popupWV.EXPECT().IsDestroyed().Return(false).Maybe()522 popupWV.EXPECT().IsDestroyed().Return(false).Maybe()
459- popupWV.EXPECT().LoadURI(mock.Anything, "https://accounts.google.com/o/oauth2/v2/auth?first").RunAndReturn(func(context.Context, string) error {523+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error {
460- currentURI = "https://accounts.google.com/o/oauth2/v2/auth?first"524+ currentURI = "https://example.com/first"
461 return nil525 return nil
462 }).Once()526 }).Once()
463- popupWV.EXPECT().LoadURI(mock.Anything, "https://accounts.google.com/o/oauth2/v2/auth?second").RunAndReturn(func(context.Context, string) error {527+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error {
464- currentURI = "https://accounts.google.com/o/oauth2/v2/auth?second"528+ currentURI = "https://example.com/second"
465 return nil529 return nil
466 }).Once()530 }).Once()
467 531
@@ -474,27 +538,78 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
474 popups: newPopupManager(),538 popups: newPopupManager(),
475 }539 }
476 c.SetPopupConfig(factory, nil, nil)540 c.SetPopupConfig(factory, nil, nil)
541+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
477 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {542 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
478 insertCalls++543 insertCalls++
479 return nil544 return nil
480 })545 })
481 546
482 first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{547 first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
483- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?first",548+ TargetURI: "https://example.com/first",
484- FrameName: "g_credential_picker_x",549+ FrameName: "shared-pane",
485 IsUserGesture: true,550 IsUserGesture: true,
486 })551 })
487 require.Same(t, popupWV, first)552 require.Same(t, popupWV, first)
488 553
489 second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{554 second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
490- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?second",555+ TargetURI: "https://example.com/second",
491- FrameName: "g_credential_picker_x",556+ FrameName: "shared-pane",
492 IsUserGesture: true,557 IsUserGesture: true,
493 })558 })
494 require.Same(t, popupWV, second)559 require.Same(t, popupWV, second)
495 560
496 assert.Equal(t, 1, insertCalls)561 assert.Equal(t, 1, insertCalls)
497- assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth?second", currentURI)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)
498 }613 }
499 614
500 // ---------------------------------------------------------------------------615 // ---------------------------------------------------------------------------
@@ -598,7 +713,7 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
598 c := &Coordinator{}713 c := &Coordinator{}
599 714
600 factory := &stubFactory{}715 factory := &stubFactory{}
601- cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorSplit}716+ cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
602 genID := func() string { return "test-id" }717 genID := func() string { return "test-id" }
603 718
604 c.SetPopupConfig(factory, cfg, genID)719 c.SetPopupConfig(factory, cfg, genID)
@@ -622,32 +737,27 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
622 737
623 type popupNavigationWebViewStub struct {738 type popupNavigationWebViewStub struct {
624 *mocks.MockWebView739 *mocks.MockWebView
625- primed []string740+ primed []string
741+ browsingContextDecision dto.HostDecision
742+ hasBrowsingContextDecision bool
626 }743 }
627 744
628 func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {745 func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
629 s.primed = append(s.primed, uri)746 s.primed = append(s.primed, uri)
630 }747 }
631 748
632-func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}749+func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
633-func (*popupNavigationWebViewStub) SetOnClose(func()) {}750+ s.browsingContextDecision = decision
634-func (*popupNavigationWebViewStub) Show() {}751+ s.hasBrowsingContextDecision = true
635-
636-type popupOpenerBridgeWebViewStub struct {
637- *mocks.MockWebView
638- parent port.WebView
639- noJavaScriptAccess bool
640 }752 }
641 753
642-func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {754+func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
643- s.parent = parent755+ return s.browsingContextDecision, s.hasBrowsingContextDecision
644- s.noJavaScriptAccess = noJavaScriptAccess
645 }756 }
646 757
647-func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {}758+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
648-func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) {759+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
649-}760+func (*popupNavigationWebViewStub) Show() {}
650-func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
651 761
652 // stubFactory satisfies port.WebViewFactory without importing the mocks package.762 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
653 type stubFactory struct{}763 type stubFactory struct{}
M internal/ui/input/global_shortcuts.go +22 -1
@@ -399,9 +399,14 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
399 }399 }
400 400
401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
402- if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {402+ if h == nil {
403 return false403 return false
404 }404 }
405+ if !isRepeatedGlobalShortcutSuppressed(action) {
406+ if _, ok := ParseFloatingProfileTarget(action); !ok {
407+ return false
408+ }
409+ }
405 if h.lastDispatchAt == nil {410 if h.lastDispatchAt == nil {
406 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts411 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
407 // rather than letting a stale GTK callback repeat destructive actions.412 // rather than letting a stale GTK callback repeat destructive actions.
@@ -419,6 +424,22 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
419 case ActionGoBack,424 case ActionGoBack,
420 ActionGoForward,425 ActionGoForward,
421 ActionZoomReset,426 ActionZoomReset,
427+ ActionReload,
428+ ActionHardReload,
429+ ActionPrintPage,
430+ ActionOpenOmnibox,
431+ ActionOpenFind,
432+ ActionOpenDevTools,
433+ ActionToggleFullscreen,
434+ ActionToggleFloatingPane,
435+ ActionToggleHistorySystemView,
436+ ActionToggleFavoritesSystemView,
437+ ActionToggleConfigSystemView,
438+ ActionCopyURL,
439+ ActionConsumeOrExpelLeft,
440+ ActionConsumeOrExpelRight,
441+ ActionConsumeOrExpelUp,
442+ ActionConsumeOrExpelDown,
422 ActionClosePane,443 ActionClosePane,
423 ActionCloseTab,444 ActionCloseTab,
424 ActionQuit,445 ActionQuit,
M internal/ui/input/global_shortcuts_test.go +48 -0
@@ -120,6 +120,54 @@ func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
120 }120 }
121 }121 }
122 122
123+func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *testing.T) {
124+ actions := []Action{
125+ ActionPrintPage,
126+ ActionReload,
127+ ActionHardReload,
128+ ActionToggleFullscreen,
129+ ActionToggleHistorySystemView,
130+ ActionOpenOmnibox,
131+ ActionOpenFind,
132+ ActionOpenDevTools,
133+ ActionToggleFloatingPane,
134+ ActionToggleFavoritesSystemView,
135+ ActionToggleConfigSystemView,
136+ ActionCopyURL,
137+ ActionConsumeOrExpelLeft,
138+ ActionConsumeOrExpelRight,
139+ ActionConsumeOrExpelUp,
140+ ActionConsumeOrExpelDown,
141+ }
142+
143+ for _, action := range actions {
144+ t.Run(string(action), func(t *testing.T) {
145+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
146+ now := time.Unix(100, 0)
147+
148+ if h.suppressRepeatedShortcut(action, now) {
149+ t.Fatalf("first %s dispatch was suppressed", action)
150+ }
151+ if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
152+ t.Fatalf("repeated %s dispatch was not suppressed", action)
153+ }
154+ })
155+ }
156+}
157+
158+func TestGlobalShortcutHandlerSuppressesRepeatedFloatingProfileAction(t *testing.T) {
159+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
160+ now := time.Unix(100, 0)
161+ action := NewFloatingProfileAction("work", "https://example.com")
162+
163+ if h.suppressRepeatedShortcut(action, now) {
164+ t.Fatal("first floating-profile dispatch was suppressed")
165+ }
166+ if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
167+ t.Fatal("repeated floating-profile dispatch was not suppressed")
168+ }
169+}
170+
123 func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {171 func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
124 if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {172 if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
125 t.Fatal("ActionSwitchLastTab should be suppressed")173 t.Fatal("ActionSwitchLastTab should be suppressed")
A internal/ui/native_popup_window.go +182 -0
@@ -0,0 +1,182 @@
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/logging"
11+ "github.com/bnema/dumber/internal/ui/coordinator/content"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/window"
14+ "github.com/bnema/puregotk/v4/gtk"
15+)
16+
17+type nativePopupWindow struct {
18+ popupID port.WebViewID
19+ parentPaneID entity.PaneID
20+ parentWindowID string
21+ popupWindow *window.PopupWindow
22+ webView port.WebView
23+ closeOnce sync.Once
24+}
25+
26+type nativePopupDestroyer interface {
27+ Destroy()
28+}
29+
30+var newPopupWindow = window.NewPopup
31+
32+func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {
33+ if wv != nil && !wv.IsDestroyed() {
34+ wv.Destroy()
35+ }
36+ if popupShell != nil {
37+ popupShell.Destroy()
38+ }
39+}
40+
41+func prepareNativePopupContentWidget(widget layout.Widget) (*gtk.Widget, error) {
42+ if widget == nil {
43+ return nil, fmt.Errorf("failed to wrap native popup webview widget")
44+ }
45+ gtkWidget := widget.GtkWidget()
46+ if gtkWidget == nil {
47+ return nil, fmt.Errorf("failed to wrap native popup webview widget")
48+ }
49+ widget.SetHexpand(true)
50+ widget.SetVexpand(true)
51+ return gtkWidget, nil
52+}
53+
54+func (a *App) ensureNativePopupWindows() {
55+ if a.nativePopupWindows == nil {
56+ a.nativePopupWindows = make(map[port.WebViewID]*nativePopupWindow)
57+ }
58+}
59+
60+func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePopupInput) error {
61+ if a == nil || a.gtkApp == nil {
62+ return fmt.Errorf("gtk application not available for native popup")
63+ }
64+ if input.PopupWebView == nil {
65+ return fmt.Errorf("native popup webview is nil")
66+ }
67+
68+ popupShell, err := newPopupWindow(ctx, a.gtkApp)
69+ if err != nil {
70+ destroyFailedNativePopupSetup(nil, input.PopupWebView)
71+ return err
72+ }
73+ if a.contentCoord == nil {
74+ destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
75+ return fmt.Errorf("content coordinator not available for native popup")
76+ }
77+ widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
78+ gtkWidget, err := prepareNativePopupContentWidget(widget)
79+ if err != nil {
80+ destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
81+ return err
82+ }
83+ popupShell.SetContent(gtkWidget)
84+
85+ parentWindowID := ""
86+ if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
87+ parentWindowID = bw.id
88+ }
89+
90+ popupID := input.PopupWebView.ID()
91+ state := &nativePopupWindow{
92+ popupID: popupID,
93+ parentPaneID: input.ParentPaneID,
94+ parentWindowID: parentWindowID,
95+ popupWindow: popupShell,
96+ webView: input.PopupWebView,
97+ }
98+ popupShell.SetTitle("Dumber")
99+
100+ a.ensureNativePopupWindows()
101+ a.nativePopupWindows[popupID] = state
102+
103+ if popupShell.Window() != nil {
104+ closeRequestCb := func(_ gtk.Window) bool {
105+ a.releaseNativePopupWindow(popupID, false, false)
106+ return false
107+ }
108+ popupShell.Window().ConnectCloseRequest(&closeRequestCb)
109+ }
110+
111+ if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
112+ aborter.SetNativePopupHostAbort(func() {
113+ a.dispatchOnMainThread(func() {
114+ a.releaseNativePopupWindow(popupID, true, false)
115+ })
116+ })
117+ }
118+ if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok {
119+ lifecycle.SetOnReadyToShow(func() {
120+ a.dispatchOnMainThread(func() {
121+ a.showNativePopupWindow(popupID)
122+ })
123+ })
124+ lifecycle.SetOnClose(func() {
125+ a.dispatchOnMainThread(func() {
126+ a.releaseNativePopupWindow(popupID, true, false)
127+ })
128+ })
129+ } else {
130+ popupShell.Show()
131+ }
132+ if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
133+ oauthWV.AddCloseCallback(func() {
134+ a.dispatchOnMainThread(func() {
135+ a.releaseNativePopupWindow(popupID, true, false)
136+ })
137+ })
138+ }
139+ if a.contentCoord != nil && input.ObserveOAuthAutoClose {
140+ a.contentCoord.ObserveNativePopupAuth(ctx, input)
141+ }
142+
143+ logging.FromContext(ctx).Info().
144+ Uint64("popup_id", uint64(popupID)).
145+ Str("target_uri", input.TargetURI).
146+ Str("parent_pane_id", string(input.ParentPaneID)).
147+ Msg("native popup host created")
148+ return nil
149+}
150+
151+func (a *App) showNativePopupWindow(popupID port.WebViewID) {
152+ if a == nil || a.nativePopupWindows == nil {
153+ return
154+ }
155+ if state := a.nativePopupWindows[popupID]; state != nil && state.popupWindow != nil {
156+ state.popupWindow.Show()
157+ }
158+}
159+
160+func (a *App) releaseNativePopupWindow(popupID port.WebViewID, closeWindow, destroyWindow bool) {
161+ if a == nil || a.nativePopupWindows == nil {
162+ return
163+ }
164+ state := a.nativePopupWindows[popupID]
165+ if state == nil {
166+ return
167+ }
168+ state.closeOnce.Do(func() {
169+ delete(a.nativePopupWindows, popupID)
170+ if state.webView != nil && !state.webView.IsDestroyed() {
171+ state.webView.Destroy()
172+ }
173+ if state.popupWindow != nil {
174+ switch {
175+ case destroyWindow:
176+ state.popupWindow.Destroy()
177+ case closeWindow:
178+ state.popupWindow.Close()
179+ }
180+ }
181+ })
182+}
A internal/ui/native_popup_window_test.go +93 -0
@@ -0,0 +1,93 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/ui/coordinator/content"
11+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
12+ "github.com/bnema/dumber/internal/ui/window"
13+ "github.com/bnema/puregotk/v4/gtk"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+type popupDestroySpy struct {
19+ destroyed bool
20+}
21+
22+func (p *popupDestroySpy) Destroy() {
23+ p.destroyed = true
24+}
25+
26+func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) {
27+ widget := layoutmocks.NewMockWidget(t)
28+ gtkWidget := &gtk.Widget{}
29+ widget.EXPECT().GtkWidget().Return(gtkWidget).Once()
30+ widget.EXPECT().SetHexpand(true).Once()
31+ widget.EXPECT().SetVexpand(true).Once()
32+
33+ got, err := prepareNativePopupContentWidget(widget)
34+ require.NoError(t, err)
35+ assert.Same(t, gtkWidget, got)
36+}
37+
38+func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *testing.T) {
39+ widget := layoutmocks.NewMockWidget(t)
40+ widget.EXPECT().GtkWidget().Return(nil).Once()
41+
42+ got, err := prepareNativePopupContentWidget(widget)
43+ require.Error(t, err)
44+ assert.Nil(t, got)
45+}
46+
47+func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {
48+ oldNewPopupWindow := newPopupWindow
49+ t.Cleanup(func() {
50+ newPopupWindow = oldNewPopupWindow
51+ })
52+
53+ expectedErr := errors.New("boom")
54+ newPopupWindow = func(context.Context, *gtk.Application) (*window.PopupWindow, error) {
55+ return nil, expectedErr
56+ }
57+
58+ wv := portmocks.NewMockWebView(t)
59+ wv.EXPECT().IsDestroyed().Return(false).Once()
60+ wv.EXPECT().Destroy().Once()
61+
62+ app := &App{gtkApp: &gtk.Application{}}
63+ err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
64+ require.ErrorIs(t, err, expectedErr)
65+}
66+
67+func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {
68+ wv := portmocks.NewMockWebView(t)
69+ wv.EXPECT().IsDestroyed().Return(false).Once()
70+ wv.EXPECT().Destroy().Once()
71+
72+ shell := &popupDestroySpy{}
73+ destroyFailedNativePopupSetup(shell, wv)
74+
75+ assert.True(t, shell.destroyed)
76+}
77+
78+func TestReleaseNativePopupWindow_DestroysWebViewAndRemovesState(t *testing.T) {
79+ wv := portmocks.NewMockWebView(t)
80+ wv.EXPECT().IsDestroyed().Return(false).Once()
81+ wv.EXPECT().Destroy().Once()
82+
83+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
84+ port.WebViewID(1): {
85+ popupID: port.WebViewID(1),
86+ webView: wv,
87+ },
88+ }}
89+
90+ app.releaseNativePopupWindow(port.WebViewID(1), false, false)
91+ _, ok := app.nativePopupWindows[port.WebViewID(1)]
92+ assert.False(t, ok)
93+}
A internal/ui/window/popup_window.go +100 -0
@@ -0,0 +1,100 @@
1+package window
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/rs/zerolog"
9+)
10+
11+const (
12+ popupWindowDefaultWidth = 900
13+ popupWindowDefaultHeight = 800
14+)
15+
16+// PopupWindow is a lightweight top-level GTK host for native-required popup flows.
17+type PopupWindow struct {
18+ window *gtk.ApplicationWindow
19+ content *gtk.Box
20+ currentContent *gtk.Widget
21+ logger zerolog.Logger
22+}
23+
24+func NewPopup(ctx context.Context, app *gtk.Application) (*PopupWindow, error) {
25+ log := logging.FromContext(ctx)
26+ pw := &PopupWindow{logger: log.With().Str("component", "popup-window").Logger()}
27+
28+ pw.window = gtk.NewApplicationWindow(app)
29+ if pw.window == nil {
30+ return nil, ErrWindowCreationFailed
31+ }
32+ pw.window.SetDefaultSize(popupWindowDefaultWidth, popupWindowDefaultHeight)
33+ title := windowTitle
34+ pw.window.SetTitle(&title)
35+
36+ pw.content = gtk.NewBox(gtk.OrientationVerticalValue, 0)
37+ if pw.content == nil {
38+ pw.window.Unref()
39+ return nil, ErrWidgetCreationFailed("popupWindow.content")
40+ }
41+ pw.content.SetHexpand(true)
42+ pw.content.SetVexpand(true)
43+ pw.content.SetVisible(true)
44+ pw.window.SetChild(&pw.content.Widget)
45+ return pw, nil
46+}
47+
48+func (pw *PopupWindow) SetContent(widget *gtk.Widget) {
49+ if pw == nil || pw.content == nil {
50+ return
51+ }
52+ if pw.currentContent != nil {
53+ pw.content.Remove(pw.currentContent)
54+ pw.currentContent = nil
55+ }
56+ if widget == nil {
57+ return
58+ }
59+ widget.SetVisible(true)
60+ pw.content.Append(widget)
61+ pw.currentContent = widget
62+}
63+
64+func (pw *PopupWindow) Show() {
65+ if pw == nil || pw.window == nil {
66+ return
67+ }
68+ pw.window.Present()
69+}
70+
71+func (pw *PopupWindow) Close() {
72+ if pw == nil || pw.window == nil {
73+ return
74+ }
75+ pw.window.Close()
76+}
77+
78+func (pw *PopupWindow) Destroy() {
79+ if pw == nil || pw.window == nil {
80+ return
81+ }
82+ pw.window.Destroy()
83+ pw.window = nil
84+ pw.content = nil
85+ pw.currentContent = nil
86+}
87+
88+func (pw *PopupWindow) Window() *gtk.ApplicationWindow {
89+ if pw == nil {
90+ return nil
91+ }
92+ return pw.window
93+}
94+
95+func (pw *PopupWindow) SetTitle(title string) {
96+ if pw == nil || pw.window == nil {
97+ return
98+ }
99+ pw.window.SetTitle(&title)
100+}

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