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"
377377 desc = "Open floating pane on GitHub"
378378 ```
379379
380-### Popup Behavior
380+### Browsing Context Behavior
381381
382382 | Key | Type | Default | Valid Values | Description |
383383 |-----|------|---------|--------------|-------------|
384-| `workspace.popups.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Popup opening mode |
385-| `workspace.popups.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction |
386-| `workspace.popups.open_in_new_pane` | bool | `true` | - | Open popups in workspace |
387-| `workspace.popups.follow_pane_context` | bool | `true` | - | Follow parent pane context |
388-| `workspace.popups.blank_target_behavior` | string | `"pane"` | `pane`, `tab` | `_blank` target handling |
389-| `workspace.popups.enable_smart_detection` | bool | `true` | - | Use WindowProperties for detection |
390-| `workspace.popups.oauth_auto_close` | bool | `true` | - | Auto-close OAuth popups |
384+| `workspace.browsing_contexts.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Placement mode for script-opened browsing contexts |
385+| `workspace.browsing_contexts.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction when behavior is `split` |
386+| `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | - | Allow new browsing contexts to open in the workspace |
387+| `workspace.browsing_contexts.follow_pane_context` | bool | `true` | - | Keep new browsing contexts aligned with the parent pane context |
388+| `workspace.browsing_contexts.blank_target_behavior` | string | `"stacked"` | `split`, `stacked`, `tabbed` | Placement mode for `_blank` / new-page link contexts |
389+| `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | - | Use window properties to refine browsing-context classification |
390+| `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | - | Auto-close OAuth browsing contexts after success |
391391
392392 ### Workspace Styling
393393
M docs/reference/configuration.md +7 -7
@@ -79,13 +79,13 @@
7979 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
8080 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
8181 | `workspace.floating_pane.profiles.<name>.desc` | string | | |
82-| `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
83-| `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
84-| `workspace.popups.open_in_new_pane` | bool | `true` | |
85-| `workspace.popups.follow_pane_context` | bool | `true` | |
86-| `workspace.popups.blank_target_behavior` | string | `pane` | `pane`, `tab` |
87-| `workspace.popups.enable_smart_detection` | bool | `true` | |
88-| `workspace.popups.oauth_auto_close` | bool | `true` | |
82+| `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
83+| `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
84+| `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |
85+| `workspace.browsing_contexts.follow_pane_context` | bool | `true` | |
86+| `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
87+| `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
88+| `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
8989 | `workspace.styling.border_width` | int | `1` | |
9090 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
9191 | `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 @@
33 // remain independent of specific implementations (WebKit, GTK, etc.).
44 package port
55
6-import "context"
6+import (
7+ "context"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+)
711
812 // WebViewID uniquely identifies a WebView instance.
913 type WebViewID uint64
@@ -67,6 +71,13 @@ type PopupRequest struct {
6771 IsUserGesture bool
6872 NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
6973 ParentViewID WebViewID
74+
75+ SourceBrowserID int32
76+ SourceFrameID string
77+ SourceFrameURL string
78+
79+ TargetDisposition dto.WindowDisposition
80+ WindowFeatures string
7081 }
7182
7283 // Texture represents a graphics texture returned by the engine.
M internal/domain/entity/config_types.go +14 -3
@@ -166,8 +166,9 @@ const (
166166 OmniboxInitialBehaviorNone OmniboxInitialBehavior = "none"
167167 )
168168
169-// PopupBehaviorConfig controls how popup windows are handled.
170-type PopupBehaviorConfig struct {
169+// BrowsingContextConfig controls how browsing contexts (popups, tabs, new windows) are handled.
170+// This is the canonical config type; PopupBehaviorConfig is a compatibility alias.
171+type BrowsingContextConfig struct {
171172 Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
172173
173174 Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
@@ -183,6 +184,9 @@ type PopupBehaviorConfig struct {
183184 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
184185 }
185186
187+// Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
188+type PopupBehaviorConfig = BrowsingContextConfig
189+
186190 // WorkspaceConfig holds all workspace layout and behavior settings.
187191 type WorkspaceConfig struct {
188192 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 {
196200 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
197201 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
198202
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+
200211 Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
201212 }
202213
M internal/infrastructure/cef/bridge_script_templates.go +3 -231
@@ -1,7 +1,6 @@
11 package cef
22
33 import (
4- "fmt"
54 "strings"
65
76 "github.com/bnema/dumber/internal/infrastructure/webutil"
@@ -12,234 +11,11 @@ import (
1211 // bridge nonce placeholder is replaced per navigation.
1312 var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()
1413
15-// rendererBridgeExtensionJS is assembled once from the shared popup proxy
16-// builder so the renderer-extension and fetch-based popup bridges stay aligned.
14+// rendererBridgeExtensionJS is assembled once for renderer-process bridge helpers.
1715 var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()
1816
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-
23717 func buildTrustedPageFetchBridgeBaseJS() string {
238- popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
239- patchFlag: "__dumberPopupBridgePatched",
240- helpers: popupFetchBridgeHelpersJSTemplate,
241- }))
242- return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge
18+ return clipboardSelectionFetchBridgeJSTemplate
24319 }
24420
24521 const bridgeNoncePlaceholder = "__DUMBER_BRIDGE_NONCE__"
@@ -252,11 +28,7 @@ func buildTrustedPageFetchBridgeJS(bridgeNonce string) string {
25228 }
25329
25430 func buildRendererBridgeExtensionJS() string {
255- popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
256- patchFlag: "__dumberPopupOpenPatched",
257- helpers: rendererPopupDispatchHelpersJS,
258- })
259- return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody)
31+ return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", "")
26032 }
26133
26234 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)
6363 require.Contains(t, script, "test-bridge-nonce")
6464 }
6565
66-func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
66+func TestTrustedPageFetchBridgeJS_DoesNotShimWindowOpen(t *testing.T) {
6767 script := testTrustedPageFetchBridgeJS()
6868
69- require.Contains(t, script, "window.__dumberPopupBridgePatched")
70- require.Contains(t, script, "window.open = function(url, target, features)")
71- require.Contains(t, script, "dumb:///api/popup-open")
72- require.Contains(t, script, "dumb:///api/popup-navigate")
73- require.Contains(t, script, "dumb:///api/popup-close")
74- require.Contains(t, script, "proxy_id")
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'")
69+ require.NotContains(t, script, "window.__dumberPopupBridgePatched")
70+ require.NotContains(t, script, "window.open = function(url, target, features)")
71+ require.NotContains(t, script, "dumb:///api/popup-open")
72+ require.NotContains(t, script, "dumb:///api/popup-navigate")
73+ require.NotContains(t, script, "dumb:///api/popup-close")
74+ require.NotContains(t, script, "createSyntheticPopupProxy")
8075 require.Contains(t, script, "test-bridge-nonce")
8176 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
8277 }
M internal/infrastructure/cef/renderer_bridge_test.go +7 -9
@@ -97,15 +97,13 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
9797 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
9898 }
9999
100-func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101- require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102- require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103- require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104- require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105- require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
106- require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
107- require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
108- require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
100+func TestRendererBridgeExtensionJS_DoesNotShimWindowOpenForSyntheticPopups(t *testing.T) {
101+ require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102+ require.NotContains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103+ require.NotContains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104+ require.NotContains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105+ require.NotContains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
106+ require.NotContains(t, rendererBridgeExtensionJS, "return popupProxy;")
109107 }
110108
111109 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
M internal/infrastructure/cef/webview.go +65 -25
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/puregotk/v4/glib"
1919 "github.com/bnema/puregotk/v4/gtk"
2020
21+ "github.com/bnema/dumber/internal/application/dto"
2122 "github.com/bnema/dumber/internal/application/port"
2223 "github.com/bnema/dumber/internal/logging"
2324 )
@@ -117,9 +118,12 @@ type WebView struct {
117118 // crash → redirect → crash loops.
118119 crashCount atomic.Int32
119120
120- // Callbacks set by use case layer.
121- mu sync.RWMutex
122- callbacks *port.WebViewCallbacks
121+ // Callbacks and browsing-context state set by the UI layer.
122+ mu sync.RWMutex
123+ callbacks *port.WebViewCallbacks
124+ browsingContextDecision dto.HostDecision
125+ hasBrowsingContextDecision bool
126+ nativePopupHostAbort func()
123127
124128 // Synthetic popup proxies created by the renderer bridge's window.open shim.
125129 syntheticPopupMu sync.Mutex
@@ -656,33 +660,74 @@ func (wv *WebView) isNativePopupCandidate() bool {
656660 return wv.nativePopupCandidate
657661 }
658662
659-func popupParentWindowHandle(parent *WebView) uintptr {
660- if parent == nil {
661- return 0
663+func (wv *WebView) discardNativePopupCandidate() {
664+ if wv == nil {
665+ return
662666 }
663- parent.mu.RLock()
664- host := parent.host
665- parent.mu.RUnlock()
666- if host == nil {
667- return 0
667+ var timer stoppableTimer
668+ wv.mu.Lock()
669+ timer = wv.nativePopupFallbackTimer
670+ wv.nativePopupFallbackTimer = nil
671+ 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()
668681 }
669- if handle := host.GetWindowHandle(); handle != 0 {
670- return handle
682+}
683+
684+func (wv *WebView) PreparePaneHostedBrowsingContext() {
685+ if wv == nil {
686+ return
671687 }
672- return host.GetOpenerWindowHandle()
688+ wv.discardNativePopupCandidate()
673689 }
674690
675-func (wv *WebView) discardNativePopupCandidate() {
691+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
676692 if wv == nil {
677693 return
678694 }
679695 wv.mu.Lock()
680- wv.nativePopupCandidate = false
681- wv.nativePopupParent = nil
682- wv.nativePopupID = 0
696+ wv.browsingContextDecision = decision
697+ wv.hasBrowsingContextDecision = true
698+ 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
683716 wv.mu.Unlock()
684717 }
685718
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+
686731 func (wv *WebView) awaitsNativePopupAttachment() bool {
687732 if wv == nil {
688733 return false
@@ -836,10 +881,6 @@ func (wv *WebView) prepareNativePopup(
836881 if !ok {
837882 return false
838883 }
839- parentWindowHandle := popupParentWindowHandle(prep.parent)
840- if parentWindowHandle == 0 {
841- return false
842- }
843884 client, ok := wv.activateNativePopup(popupID, targetURL)
844885 if !ok {
845886 return false
@@ -848,7 +889,7 @@ func (wv *WebView) prepareNativePopup(
848889 prep.parent.trackPendingNativePopup(popupID, wv)
849890 }
850891
851- configureNativePopupWindow(windowInfo, settings, parentWindowHandle, prep.frameRate, prep.backgroundColor)
892+ configureNativePopupWindow(windowInfo, settings, prep.frameRate, prep.backgroundColor)
852893 clientSlot.Set(client)
853894 return true
854895 }
@@ -900,11 +941,10 @@ func (wv *WebView) canPrepareNativePopupLocked() bool {
900941 func configureNativePopupWindow(
901942 windowInfo *purecef.WindowInfo,
902943 settings *purecef.BrowserSettings,
903- parentWindowHandle uintptr,
904944 frameRate int32,
905945 backgroundColor uint32,
906946 ) {
907- purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false)
947+ cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})
908948 if externalBeginFrameEnabled() {
909949 windowInfo.ExternalBeginFrameEnabled = 1
910950 }
M internal/infrastructure/cef/webview_handlers.go +80 -11
@@ -8,6 +8,7 @@ import (
88 purecef "github.com/bnema/purego-cef/cef"
99 cef2gtk "github.com/bnema/purego-cef2gtk"
1010
11+ "github.com/bnema/dumber/internal/application/dto"
1112 "github.com/bnema/dumber/internal/application/port"
1213 downloadutil "github.com/bnema/dumber/internal/domain/download"
1314 "github.com/bnema/dumber/internal/logging"
@@ -419,6 +420,24 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
419420 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
420421 }
421422
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+
422441 // ===========================================================================
423442 // LifeSpanHandler (6 methods)
424443 // ===========================================================================
@@ -429,8 +448,8 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
429448 // preserved. Otherwise we keep blocking and let the coordinator's fallback
430449 // pane handle the navigation.
431450 func (h *handlerSet) OnBeforePopup(
432- _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
433- _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
451+ browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
452+ targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
434453 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
435454 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
436455 ) bool {
@@ -450,6 +469,17 @@ func (h *handlerSet) OnBeforePopup(
450469 requestNoJavaScriptAccess = *noJavaScriptAccess
451470 }
452471 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+
453483 h.wv.runOnGTKSync(func() {
454484 popup = cb.OnCreate(port.PopupRequest{
455485 TargetURI: targetURL,
@@ -457,6 +487,10 @@ func (h *handlerSet) OnBeforePopup(
457487 IsUserGesture: userGesture != 0,
458488 NoJavaScriptAccess: requestNoJavaScriptAccess,
459489 ParentViewID: h.wv.id,
490+ SourceBrowserID: sourceBrowserID,
491+ SourceFrameID: sourceFrameID,
492+ SourceFrameURL: sourceFrameURL,
493+ TargetDisposition: mapCEFWindowDisposition(targetDisposition),
460494 })
461495 })
462496
@@ -464,17 +498,52 @@ func (h *handlerSet) OnBeforePopup(
464498 if !ok || cefPopup == nil {
465499 return true
466500 }
467- cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
468- if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
469- return false
501+
502+ decision := dto.HostDecision{}
503+ if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
504+ if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
505+ decision = got
506+ }
470507 }
471508
472- cefPopup.discardNativePopupCandidate()
473- logging.FromContext(h.currentContext()).Debug().
474- Int32("popup_id", popupID).
475- Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
476- Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
477- return true
509+ cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
510+ switch decision.Kind {
511+ case dto.HostDecisionCreateNativeWin:
512+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
513+ logging.FromContext(h.currentContext()).Debug().
514+ 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+ }
478547 }
479548
480549 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{}
9898
9999 func (stubStoppableTimer) Stop() bool { return true }
100100
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+
101134 func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
102135 wv := &WebView{
103136 nativePopupCandidate: true,
@@ -247,6 +280,60 @@ func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T
247280 require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
248281 }
249282
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+
250337 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
251338 wv := &WebView{ctx: context.Background()}
252339 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 (
7676 defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
7777 )
7878
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+
7991 // getDefaultLogDir returns the default log directory, falls back to empty string on error
8092 func getDefaultLogDir() string {
8193 logDir, err := GetLogDir()
@@ -87,6 +99,8 @@ func getDefaultLogDir() string {
8799
88100 // DefaultConfig returns the default configuration values for dumber.
89101 func DefaultConfig() *Config {
102+ browsingContextDefaults := defaultBrowsingContextConfig()
103+
90104 return &Config{
91105 Database: DatabaseConfig{
92106 // Path is set dynamically in config.Load()
@@ -254,15 +268,8 @@ func DefaultConfig() *Config {
254268 },
255269 TabBarPosition: defaultTabBarPosition,
256270 HideTabBarWhenSingleTab: true,
257- Popups: PopupBehaviorConfig{
258- Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
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- },
271+ BrowsingContexts: browsingContextDefaults,
272+ Popups: browsingContextDefaults,
266273 Styling: WorkspaceStylingConfig{
267274 BorderWidth: defaultBorderWidth,
268275 BorderColor: defaultBorderColor,
M internal/infrastructure/config/loader.go +127 -12
@@ -65,6 +65,9 @@ func NewManager() (*Manager, error) {
6565 if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
6666 return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
6767 }
68+ if err := bindBrowsingContextEnvAliases(v); err != nil {
69+ return nil, err
70+ }
6871
6972 return &Manager{
7073 viper: v,
@@ -145,17 +148,116 @@ func (m *Manager) readConfigFile() error {
145148 // transformLegacyConfig converts old-format action bindings and engine config to the new format.
146149 // This is called after reading config but before unmarshaling.
147150 func (m *Manager) transformLegacyConfig() {
151+ legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
152+
148153 rawConfig := m.viper.AllSettings()
149154 transformer := NewLegacyConfigTransformer()
150155 transformer.TransformLegacyActions(rawConfig)
151156 transformer.TransformLegacyEngineConfig(rawConfig)
152157
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+
153168 // Apply transformed config back to viper
154169 for key, value := range rawConfig {
155170 m.viper.Set(key, value)
156171 }
157172 }
158173
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+
159261 // checkLegacyFormat detects old config format and returns an error directing user to migrate.
160262 func (m *Manager) checkLegacyFormat() error {
161263 // Check if old sections exist by looking for known keys.
@@ -208,6 +310,13 @@ func normalizeConfig(config *Config) {
208310 normalizeAppearance(config)
209311 normalizeMedia(config)
210312 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
211320 }
212321
213322 func normalizeEngineConfig(config *Config) {
@@ -313,8 +422,13 @@ func (m *Manager) Save(cfg *Config) error {
313422 return fmt.Errorf("config is nil")
314423 }
315424
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+
316430 // Validate before writing so UI gets immediate errors.
317- if err := validateConfig(cfg); err != nil {
431+ if err := validateConfig(&cfgToSave); err != nil {
318432 return fmt.Errorf("configuration validation failed: %w", err)
319433 }
320434
@@ -332,14 +446,15 @@ func (m *Manager) Save(cfg *Config) error {
332446 // overwriting our in-memory config with stale viper cache
333447 m.skipNextReload = true
334448
335- if err := WriteConfigOrdered(cfg, configFile); err != nil {
449+ if err := WriteConfigOrdered(&cfgToSave, configFile); err != nil {
336450 m.skipNextReload = false // Reset on error
337451 return fmt.Errorf("failed to write config: %w", err)
338452 }
339453
340- // Update in-memory config immediately (don't wait for file watcher)
341- // This ensures subsequent Get() calls return the new values
342- m.config = cfg
454+ // Update in-memory config immediately (don't wait for file watcher).
455+ // This ensures subsequent Get() calls return normalized values even when
456+ // the watcher intentionally skips reload for our own Save().
457+ m.config = &cfgToSave
343458
344459 // Also reload from disk to sync Viper's internal state (if not watching)
345460 if !m.watching {
@@ -500,13 +615,13 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
500615 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
501616 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
502617 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
503- m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
504- m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
505- m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
506- m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
507- m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
508- m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
509- m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
618+ m.viper.SetDefault("workspace.browsing_contexts.behavior", string(defaults.Workspace.BrowsingContexts.Behavior))
619+ m.viper.SetDefault("workspace.browsing_contexts.placement", defaults.Workspace.BrowsingContexts.Placement)
620+ m.viper.SetDefault("workspace.browsing_contexts.open_in_new_pane", defaults.Workspace.BrowsingContexts.OpenInNewPane)
621+ m.viper.SetDefault("workspace.browsing_contexts.follow_pane_context", defaults.Workspace.BrowsingContexts.FollowPaneContext)
622+ m.viper.SetDefault("workspace.browsing_contexts.blank_target_behavior", defaults.Workspace.BrowsingContexts.BlankTargetBehavior)
623+ m.viper.SetDefault("workspace.browsing_contexts.enable_smart_detection", defaults.Workspace.BrowsingContexts.EnableSmartDetection)
624+ m.viper.SetDefault("workspace.browsing_contexts.oauth_auto_close", defaults.Workspace.BrowsingContexts.OAuthAutoClose)
510625 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
511626 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
512627 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) {
8787 return nil, nil
8888 }
8989
90- // Get user-defined keys with values
90+ // Get user-defined keys with values after compatibility transforms.
9191 userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
9292 if err != nil {
9393 return nil, fmt.Errorf("failed to parse user config: %w", err)
9494 }
9595
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+
96103 // Get all default keys
97104 defaultKeys := m.getAllDefaultKeys()
98105 defaultKeySet := make(map[string]bool)
@@ -106,13 +113,22 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
106113 userKeySet[k] = true
107114 }
108115
116+ legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)
109117 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+ }
110126
111127 // Find deprecated keys (in user config but not in defaults)
112- deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet)
128+ deprecatedKeys := excludeKeys(m.findDeprecatedKeys(userKeysWithValues, defaultKeySet), handledLegacyDeprecatedKeys)
113129
114130 // Find missing keys (in defaults but not in user config)
115- missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
131+ missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
116132 missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
117133
118134 // Try to match deprecated keys with missing keys (detect renames)
@@ -378,9 +394,11 @@ func (m *Migrator) Migrate() ([]string, error) {
378394 }
379395
380396 // Transform legacy action bindings (slice -> ActionBinding struct)
397+ // and migrate popups -> browsing_contexts
381398 transformer := NewLegacyConfigTransformer()
382399 transformer.TransformLegacyActions(rawConfig)
383400 transformer.TransformLegacyEngineConfig(rawConfig)
401+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
384402
385403 // Build sets of keys to remove and renames to apply
386404 keysToRemove := make(map[string]bool)
@@ -657,29 +675,71 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
657675
658676 // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
659677 func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
660- data, err := os.ReadFile(configFile)
678+ rawConfig, err := m.readRawConfig(configFile)
661679 if err != nil {
662- return nil, fmt.Errorf("failed to read config file: %w", 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)
680+ return nil, err
669681 }
670682
671- // Transform legacy action bindings before flattening
683+ // Transform legacy action bindings and popups before flattening.
672684 transformer := NewLegacyConfigTransformer()
673685 transformer.TransformLegacyActions(rawConfig)
674686 transformer.TransformLegacyEngineConfig(rawConfig)
687+ transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
675688
676- // Flatten the map to dot-notation keys with values
689+ // Flatten the map to dot-notation keys with values.
677690 result := make(map[string]any)
678691 m.flattenMapWithValues(rawConfig, "", result)
679692
680693 return result, nil
681694 }
682695
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+
683743 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
684744 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
685745 for k, v := range data {
@@ -837,6 +897,21 @@ func appendUniqueKeys(base, extra []string) []string {
837897 return base
838898 }
839899
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+
840915 func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
841916 if value == nil {
842917 return nil, false
M internal/infrastructure/config/migrate_test.go +197 -0
@@ -3,8 +3,10 @@ package config
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78
9+ port "github.com/bnema/dumber/internal/application/port"
810 "github.com/stretchr/testify/assert"
911 "github.com/stretchr/testify/require"
1012 )
@@ -916,6 +918,201 @@ func TestMigrator_DeleteNestedKey(t *testing.T) {
916918 })
917919 }
918920
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+
9191116 func TestMigrator_ReadRawConfig(t *testing.T) {
9201117 m := NewMigrator()
9211118
M internal/infrastructure/config/schema.go +5 -4
@@ -68,8 +68,11 @@ const (
6868 OmniboxInitialBehaviorNone = entity.OmniboxInitialBehaviorNone
6969 )
7070
71-// PopupBehaviorConfig defines handling for popup windows.
72-type PopupBehaviorConfig = entity.PopupBehaviorConfig
71+// BrowsingContextConfig defines handling for browsing contexts (popups, tabs, new windows).
72+type BrowsingContextConfig = entity.BrowsingContextConfig
73+
74+// Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
75+type PopupBehaviorConfig = entity.BrowsingContextConfig
7376
7477 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
7578 type WorkspaceConfig = entity.WorkspaceConfig
@@ -381,5 +384,3 @@ type DownloadsConfig struct {
381384 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
382385 Path string `mapstructure:"path" yaml:"path" toml:"path"`
383386 }
384-
385-
M internal/infrastructure/config/schema_provider.go +15 -15
@@ -485,56 +485,56 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
485485 Description: "Named floating pane URL profiles with keys, url, and optional desc",
486486 Section: SectionWorkspace,
487487 },
488- // Popups
488+ // Browsing contexts
489489 {
490- Key: "workspace.popups.behavior",
490+ Key: "workspace.browsing_contexts.behavior",
491491 Type: "string",
492- Default: string(defaults.Workspace.Popups.Behavior),
492+ Default: string(defaults.Workspace.BrowsingContexts.Behavior),
493493 Description: "How to handle JavaScript popup windows",
494494 Values: []string{"split", "stacked", "tabbed", "windowed"},
495495 Section: SectionWorkspace,
496496 },
497497 {
498- Key: "workspace.popups.placement",
498+ Key: "workspace.browsing_contexts.placement",
499499 Type: "string",
500- Default: defaults.Workspace.Popups.Placement,
500+ Default: defaults.Workspace.BrowsingContexts.Placement,
501501 Description: "Placement direction for split popups",
502502 Values: []string{"right", "left", "top", "bottom"},
503503 Section: SectionWorkspace,
504504 },
505505 {
506- Key: "workspace.popups.blank_target_behavior",
506+ Key: "workspace.browsing_contexts.blank_target_behavior",
507507 Type: "string",
508- Default: defaults.Workspace.Popups.BlankTargetBehavior,
508+ Default: defaults.Workspace.BrowsingContexts.BlankTargetBehavior,
509509 Description: "How to handle target=\"_blank\" links",
510510 Values: []string{"split", "stacked", "tabbed"},
511511 Section: SectionWorkspace,
512512 },
513513 {
514- Key: "workspace.popups.open_in_new_pane",
514+ Key: "workspace.browsing_contexts.open_in_new_pane",
515515 Type: "bool",
516- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OpenInNewPane),
516+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.OpenInNewPane),
517517 Description: "Open popups in workspace instead of blocking",
518518 Section: SectionWorkspace,
519519 },
520520 {
521- Key: "workspace.popups.follow_pane_context",
521+ Key: "workspace.browsing_contexts.follow_pane_context",
522522 Type: "bool",
523- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.FollowPaneContext),
523+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.FollowPaneContext),
524524 Description: "Popup placement follows parent pane context",
525525 Section: SectionWorkspace,
526526 },
527527 {
528- Key: "workspace.popups.enable_smart_detection",
528+ Key: "workspace.browsing_contexts.enable_smart_detection",
529529 Type: "bool",
530- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.EnableSmartDetection),
530+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.EnableSmartDetection),
531531 Description: "Use window properties to detect popup intent",
532532 Section: SectionWorkspace,
533533 },
534534 {
535- Key: "workspace.popups.oauth_auto_close",
535+ Key: "workspace.browsing_contexts.oauth_auto_close",
536536 Type: "bool",
537- Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OAuthAutoClose),
537+ Default: fmt.Sprintf("%t", defaults.Workspace.BrowsingContexts.OAuthAutoClose),
538538 Description: "Auto-close OAuth popups after success",
539539 Section: SectionWorkspace,
540540 },
M internal/infrastructure/config/transform_legacy.go +37 -0
@@ -97,6 +97,43 @@ func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]an
9797 }
9898 }
9999
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+
100137 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
101138 switch {
102139 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 {
117117
118118 func validatePopups(config *Config) []string {
119119 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 {
121123 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
122124 default:
123125 validationErrors = append(validationErrors, fmt.Sprintf(
124- "workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
125- config.Workspace.Popups.Behavior,
126+ "workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
127+ config.Workspace.BrowsingContexts.Behavior,
126128 ))
127129 }
128130
129- if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
130- switch config.Workspace.Popups.Placement {
131+ if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit {
132+ switch config.Workspace.BrowsingContexts.Placement {
131133 case "right", "left", "top", "bottom":
132134 default:
133135 validationErrors = append(validationErrors, fmt.Sprintf(
134- "workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",
135- config.Workspace.Popups.Placement,
136+ "workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)",
137+ config.Workspace.BrowsingContexts.Placement,
136138 ))
137139 }
138140 }
139141
140- switch config.Workspace.Popups.BlankTargetBehavior {
142+ switch config.Workspace.BrowsingContexts.BlankTargetBehavior {
141143 case "split", "stacked", "tabbed":
142144 default:
143145 validationErrors = append(validationErrors, fmt.Sprintf(
144- "workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
145- config.Workspace.Popups.BlankTargetBehavior,
146+ "workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
147+ config.Workspace.BrowsingContexts.BlankTargetBehavior,
146148 ))
147149 }
148150 return validationErrors
M internal/infrastructure/config/watcher.go +2 -0
@@ -87,6 +87,8 @@ func (m *Manager) reload() error {
8787 return err
8888 }
8989
90+ m.transformLegacyConfig()
91+
9092 config := &Config{}
9193 if err := m.viper.Unmarshal(config); err != nil {
9294 return err
M internal/infrastructure/webkit/webview.go +53 -15
@@ -10,6 +10,7 @@ import (
1010 "sync/atomic"
1111 "time"
1212
13+ "github.com/bnema/dumber/internal/application/dto"
1314 "github.com/bnema/dumber/internal/application/port"
1415 downloadutil "github.com/bnema/dumber/internal/domain/download"
1516 "github.com/bnema/dumber/internal/domain/entity"
@@ -147,20 +148,23 @@ type WebView struct {
147148 generation atomic.Uint64
148149
149150 // Callbacks (set by UI layer)
150- OnLoadChanged func(LoadEvent)
151- OnTitleChanged func(string)
152- OnURIChanged func(string)
153- OnProgressChanged func(float64)
154- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
155- OnClose func()
156- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
157- OnReadyToShow func() // Called when popup is ready to display
158- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
159- OnEnterFullscreen func() bool // Return true to prevent fullscreen
160- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
161- 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- OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
151+ OnLoadChanged func(LoadEvent)
152+ OnTitleChanged func(string)
153+ OnURIChanged func(string)
154+ OnProgressChanged func(float64)
155+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
156+ OnClose func()
157+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
158+ OnReadyToShow func() // Called when popup is ready to display
159+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
160+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
161+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
162+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
163+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
164+ OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
165+ browsingContextDecision dto.HostDecision
166+ hasBrowsingContextDecision bool
167+ nativePopupHostAbort func()
164168
165169 // PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
166170 // Return true to indicate the request was handled. Call allow()/deny() to respond.
@@ -518,6 +522,34 @@ func (wv *WebView) connectCloseSignal() {
518522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
519523 }
520524
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+
521553 func (wv *WebView) connectCreateSignal() {
522554 createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
523555 if wv.OnCreate == nil {
@@ -1893,9 +1925,12 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
18931925 wv.OnWebProcessTerminated = nil
18941926 wv.OnPermissionRequest = nil
18951927
1896- // 3. Clear async callback references
1928+ // 3. Clear async callback references and popup-hosting state
18971929 wv.mu.Lock()
18981930 wv.asyncCallbacks = nil
1931+ wv.browsingContextDecision = dto.HostDecision{}
1932+ wv.hasBrowsingContextDecision = false
1933+ wv.nativePopupHostAbort = nil
18991934 wv.mu.Unlock()
19001935
19011936 // 4. Unparent from GTK hierarchy (must happen before process termination)
@@ -1963,6 +1998,9 @@ func (wv *WebView) ResetForPoolReuse() {
19631998 wv.isLoading = false
19641999 wv.asyncCallbacks = nil
19652000 wv.runJSErrorStats = make(map[string]runJSErrorStat)
2001+ wv.browsingContextDecision = dto.HostDecision{}
2002+ wv.hasBrowsingContextDecision = false
2003+ wv.nativePopupHostAbort = nil
19662004 wv.lastProgressUpdate.Store(0)
19672005 wv.mu.Unlock()
19682006
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 {
7979 browserWindows map[string]*browserWindow
8080 browserWindowOrder []string // registration order of window IDs
8181 lastFocusedWindowID string
82+ nativePopupWindows map[port.WebViewID]*nativePopupWindow
8283 browserWindowFactory func(context.Context, string) (*browserWindow, error)
8384 dispatchOnMainThread func(func())
8485
@@ -2918,6 +2919,15 @@ func (a *App) onShutdown(ctx context.Context) {
29182919 for _, tabID := range tabIDs {
29192920 a.releaseFloatingSessionsForTab(ctx, tabID)
29202921 }
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+ }
29212931 if a.engine != nil {
29222932 if err := a.engine.Close(); err != nil {
29232933 log.Warn().Err(err).Msg("failed to close engine")
@@ -3097,15 +3107,29 @@ func (a *App) initCoordinators(ctx context.Context) {
30973107 }
30983108 a.contentCoord.SetPopupConfig(
30993109 a.engine.Factory(),
3100- &a.deps.Config.Workspace.Popups,
3110+ &a.deps.Config.Workspace.BrowsingContexts,
31013111 a.generateID,
31023112 )
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+ })
31033120 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+ }
31043124 return a.wsCoord.InsertPopup(ctx, input)
31053125 })
31063126 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
3127+ if bw := a.browserWindowForAnyPane(paneID); bw != nil {
3128+ a.activateBrowserWindow(bw)
3129+ }
31073130 return a.wsCoord.ClosePaneByID(ctx, paneID)
31083131 })
3132+ a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow)
31093133 // Wire tabbed popup behavior to create new tabs in the originating window.
31103134 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
31113135
M internal/ui/browser_window.go +3 -0
@@ -287,6 +287,9 @@ func (a *App) removeBrowserWindow(id string) {
287287 if removed != nil {
288288 removed.clearShellState()
289289 }
290+ if a.contentCoord != nil {
291+ a.contentCoord.ClearPopupNamedContextsForWindow(id)
292+ }
290293 delete(a.browserWindows, id)
291294 fallback := a.deterministicBrowserWindowFallback()
292295 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)
5858 if wv == nil {
5959 return
6060 }
61+ c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)
62+ c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())
6163 c.clearPendingAppearance(paneID)
6264
6365 // 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) {
131131 t.Parallel()
132132
133133 wv := mocks.NewMockWebView(t)
134+ wv.EXPECT().ID().Return(port.WebViewID(11)).Maybe()
134135 // idleInhibitor is nil so these won't be called
135136 wv.EXPECT().IsFullscreen().Return(false).Maybe()
136137 wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
@@ -148,6 +149,41 @@ func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
148149 assert.False(t, exists, "webview should be removed from map after release")
149150 }
150151
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+
151187 func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
152188 t.Parallel()
153189
@@ -165,6 +201,7 @@ func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
165201 t.Parallel()
166202
167203 wv := mocks.NewMockWebView(t)
204+ wv.EXPECT().ID().Return(port.WebViewID(12)).Maybe()
168205 wv.EXPECT().IsFullscreen().Return(true)
169206 wv.EXPECT().IsPlayingAudio().Return(false)
170207
@@ -186,6 +223,7 @@ func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
186223 t.Parallel()
187224
188225 wv := mocks.NewMockWebView(t)
226+ wv.EXPECT().ID().Return(port.WebViewID(13)).Maybe()
189227 wv.EXPECT().IsFullscreen().Return(false)
190228 wv.EXPECT().IsPlayingAudio().Return(true)
191229
@@ -207,6 +245,7 @@ func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
207245 t.Parallel()
208246
209247 wv := mocks.NewMockWebView(t)
248+ wv.EXPECT().ID().Return(port.WebViewID(14)).Maybe()
210249 // idleInhibitor is nil so these won't be called
211250 wv.EXPECT().IsFullscreen().Return(false).Maybe()
212251 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
221221
222222 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
223223 // 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 calling
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-
224+// and a long safety timeout for stuck popups.
235225 func (c *Coordinator) setupOAuthAutoClose(
236226 ctx context.Context,
237227 paneID entity.PaneID,
@@ -313,11 +303,9 @@ func (c *Coordinator) setupOAuthAutoClose(
313303
314304 // Start safety timer immediately.
315305 startSafetyTimer()
316- deferCloseOnNavigation := popupUsesSyntheticOpenerSignals(wv)
317306 log.Debug().
318307 Str("pane", string(paneID)).
319308 Uint64("popup_id", uint64(popupID)).
320- Bool("synthetic_opener_active", deferCloseOnNavigation).
321309 Msg("oauth auto-close configured")
322310
323311 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
@@ -326,25 +314,8 @@ func (c *Coordinator) setupOAuthAutoClose(
326314 return
327315 }
328316 c.capturePopupOAuthState(popupID, uri)
329- if deferCloseOnNavigation {
330- cancelSafetyTimer()
331- return
332- }
333317 requestOAuthClose("navigation")
334318 })
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- }
348319
349320 // Cancel safety timer on any close path.
350321 oauthWV.AddCloseCallback(func() {
M internal/ui/coordinator/content/oauth_test.go +89 -29
@@ -14,17 +14,6 @@ import (
1414 "github.com/bnema/dumber/internal/domain/entity"
1515 )
1616
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-
2817 type popupOAuthAutoCloseWebViewStub struct {
2918 *portmocks.MockWebView
3019 active bool
@@ -32,6 +21,7 @@ type popupOAuthAutoCloseWebViewStub struct {
3221 closeCallbacks []func()
3322 openerMessageCallbacks []func()
3423 openerNavigateCallbacks []func(string)
24+ closeCount int
3525 }
3626
3727 func (s *popupOAuthAutoCloseWebViewStub) AddNavigationCallback(fn func(string)) {
@@ -42,7 +32,7 @@ func (s *popupOAuthAutoCloseWebViewStub) AddCloseCallback(fn func()) {
4232 s.closeCallbacks = append(s.closeCallbacks, fn)
4333 }
4434
45-func (*popupOAuthAutoCloseWebViewStub) Close() {}
35+func (s *popupOAuthAutoCloseWebViewStub) Close() { s.closeCount++ }
4636
4737 func (*popupOAuthAutoCloseWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
4838
@@ -56,16 +46,6 @@ func (s *popupOAuthAutoCloseWebViewStub) AddOpenerNavigationCallback(fn func(str
5646
5747 func (s *popupOAuthAutoCloseWebViewStub) HasActivePopupOpenerBridge() bool { return s.active }
5848
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-
6949 func TestComposeOnClose_Order(t *testing.T) {
7050 var calls []string
7151
@@ -119,19 +99,99 @@ func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
11999 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
120100 }
121101
122-func TestSetupOAuthAutoClose_OpenerNavigationIgnoresNonTerminalURI(t *testing.T) {
123- popupID := port.WebViewID(1000)
124- wv := &popupOAuthAutoCloseWebViewStub{
125- MockWebView: portmocks.NewMockWebView(t),
126- active: true,
102+func TestObserveNativePopupAuth_CapturesOAuthStateAndSchedulesParentResumeOnClose(t *testing.T) {
103+ popupID := port.WebViewID(999)
104+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
105+ wv.EXPECT().ID().Return(popupID).Once()
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()
127167 }
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)}
128188 c := &Coordinator{popups: newPopupManager()}
129189 c.trackOAuthPopup(popupID, entity.PaneID("parent-pane"), "https://example.com/start")
130190
131191 c.setupOAuthAutoClose(context.Background(), entity.PaneID("popup-pane"), popupID, wv)
132- require.Len(t, wv.openerNavigateCallbacks, 1)
192+ require.Len(t, wv.navigationCallbacks, 1)
133193
134- wv.openerNavigateCallbacks[0]("https://example.com/intermediate")
194+ wv.navigationCallbacks[0]("https://example.com/intermediate")
135195
136196 state, ok := c.popups.popupOAuth[popupID]
137197 require.True(t, ok)
M internal/ui/coordinator/content/popup.go +26 -15
@@ -78,19 +78,6 @@ type PendingPopup struct {
7878 CreatedAt time.Time
7979 }
8080
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-
9481 // InsertPopupInput contains the data needed to insert a popup into the workspace.
9582 type InsertPopupInput struct {
9683 // ParentPaneID is the pane that spawned this popup.
@@ -116,8 +103,20 @@ type InsertPopupInput struct {
116103 TargetURI string
117104 }
118105
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+
119118 // 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 {
121120 if cfg == nil {
122121 return entity.PopupBehaviorSplit // Default
123122 }
@@ -146,12 +145,20 @@ func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.Po
146145 // SetPopupConfig configures popup handling.
147146 func (c *Coordinator) SetPopupConfig(
148147 factory port.WebViewFactory,
149- popupConfig *entity.PopupBehaviorConfig,
148+ popupConfig *entity.BrowsingContextConfig,
150149 generateID func() string,
151150 ) {
152151 c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
153152 }
154153
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+
155162 // SetOnInsertPopup sets the callback to insert popups into the workspace.
156163 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
157164 c.ensurePopupManager().setOnInsertPopup(fn)
@@ -162,6 +169,10 @@ func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.
162169 c.ensurePopupManager().setOnClosePane(fn)
163170 }
164171
172+func (c *Coordinator) SetOnOpenNativePopup(fn func(ctx context.Context, input NativePopupInput) error) {
173+ c.ensurePopupManager().setOnOpenNativePopup(fn)
174+}
175+
165176 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
166177 // Returns nil if popup handling is not configured.
167178 func (c *Coordinator) buildPopupCreateHandler(
M internal/ui/coordinator/content/popup_manager.go +230 -178
@@ -2,15 +2,14 @@ package content
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "strings"
87 "sync"
98 "time"
109
10+ "github.com/bnema/dumber/internal/application/dto"
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/domain/entity"
13- domainerrors "github.com/bnema/dumber/internal/domain/errors"
1413 "github.com/bnema/dumber/internal/logging"
1514 )
1615
@@ -18,18 +17,19 @@ import (
1817 // adapter layer so popup pane bookkeeping and workspace orchestration do not
1918 // leak into the application/usecase or domain layers.
2019 type popupManager struct {
21- factory port.WebViewFactory
22- popupConfig *entity.PopupBehaviorConfig
23- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24- onClosePane func(ctx context.Context, paneID entity.PaneID) error
25- generatePaneID func() string
26- pendingPopups map[port.WebViewID]*PendingPopup
27- namedPopups map[namedPopupKey]*namedPopupState
28- popupOAuth map[port.WebViewID]*popupOAuthState
29- popupRefresh map[entity.PaneID]*time.Timer
30- relatedPopupUnsupported bool
31- relatedPopupSupportDetected bool
32- mu sync.RWMutex
20+ factory port.WebViewFactory
21+ popupConfig *entity.BrowsingContextConfig
22+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
23+ onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
24+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
25+ generatePaneID func() string
26+ windowIDForPane func(entity.PaneID) (string, bool)
27+ policy browsingContextPolicy
28+ namedContexts *namedBrowsingContextRegistry
29+ pendingPopups map[port.WebViewID]*PendingPopup
30+ popupOAuth map[port.WebViewID]*popupOAuthState
31+ popupRefresh map[entity.PaneID]*time.Timer
32+ mu sync.RWMutex
3333 }
3434
3535 type popupOAuthState struct {
@@ -82,8 +82,8 @@ func (pm *popupManager) ensureInitialized() {
8282 if pm.pendingPopups == nil {
8383 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
8484 }
85- if pm.namedPopups == nil {
86- pm.namedPopups = make(map[namedPopupKey]*namedPopupState)
85+ if pm.namedContexts == nil {
86+ pm.namedContexts = newNamedBrowsingContextRegistry()
8787 }
8888 if pm.popupOAuth == nil {
8989 pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
@@ -95,7 +95,7 @@ func (pm *popupManager) ensureInitialized() {
9595
9696 func (pm *popupManager) setConfig(
9797 factory port.WebViewFactory,
98- popupConfig *entity.PopupBehaviorConfig,
98+ popupConfig *entity.BrowsingContextConfig,
9999 generateID func() string,
100100 ) {
101101 if pm == nil {
@@ -105,11 +105,28 @@ func (pm *popupManager) setConfig(
105105 pm.factory = factory
106106 pm.popupConfig = popupConfig
107107 pm.generatePaneID = generateID
108+}
108109
109- pm.mu.Lock()
110- pm.relatedPopupUnsupported = false
111- pm.relatedPopupSupportDetected = false
112- pm.mu.Unlock()
110+func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
111+ if pm == nil {
112+ return
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+ }
113130 }
114131
115132 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
126143 pm.onClosePane = fn
127144 }
128145
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+
129153 func (pm *popupManager) createPopupPane(
130154 popupID port.WebViewID,
131155 parentPaneID entity.PaneID,
@@ -147,70 +171,45 @@ func (pm *popupManager) createPopupPane(
147171 return paneID, popupPane
148172 }
149173
150-func (pm *popupManager) relatedPopupSupportDisabled() bool {
151- if pm == nil {
152- return false
153- }
154- pm.mu.RLock()
155- defer pm.mu.RUnlock()
156- return pm.relatedPopupSupportDetected && pm.relatedPopupUnsupported
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
174+func (pm *popupManager) lookupReusableNamedPopup(
175+ parentPaneID entity.PaneID,
176+ frameName string,
177+ hooks popupCoordinatorHooks,
178+) (port.WebView, bool) {
179+ if pm == nil || pm.namedContexts == nil {
180+ return nil, false
172181 }
173- pm.mu.Lock()
174- defer pm.mu.Unlock()
175- pm.relatedPopupSupportDetected = true
176- pm.relatedPopupUnsupported = false
177-}
178182
179-func (pm *popupManager) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
180- if pm == nil || !isReusableNamedPopupFrame(frameName) {
183+ name := reusableBrowsingContextName(frameName)
184+ if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
181185 return nil, false
182186 }
183187
184- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
185-
186- pm.mu.RLock()
187- state, ok := pm.namedPopups[key]
188- pm.mu.RUnlock()
189- if !ok || state == nil || state.WebView == nil {
188+ windowID, ok := pm.windowIDForPane(parentPaneID)
189+ if !ok || windowID == "" {
190190 return nil, false
191191 }
192192
193- if state.WebView.IsDestroyed() {
194- pm.mu.Lock()
195- if current, ok := pm.namedPopups[key]; ok && current == state {
196- delete(pm.namedPopups, key)
197- }
198- pm.mu.Unlock()
193+ _, wv, ok := pm.namedContexts.Lookup(windowID, name, hooks.getWebView, pm.windowIDForPane)
194+ if !ok {
199195 return nil, false
200196 }
201-
202- return state, true
197+ return wv, true
203198 }
204199
205-func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, wv port.WebView) {
206- if pm == nil || !isReusableNamedPopupFrame(frameName) || wv == nil {
200+func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
201+ if pm == nil || pm.namedContexts == nil || wv == nil {
207202 return
208203 }
209-
210- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
211- pm.mu.Lock()
212- pm.namedPopups[key] = &namedPopupState{WebView: wv}
213- pm.mu.Unlock()
204+ name := reusableBrowsingContextName(frameName)
205+ if name == "" || pm.windowIDForPane == nil {
206+ return
207+ }
208+ windowID, ok := pm.windowIDForPane(parentPaneID)
209+ if !ok || windowID == "" {
210+ return
211+ }
212+ pm.namedContexts.Register(windowID, name, paneID, wv.ID())
214213 }
215214
216215 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
@@ -225,16 +224,24 @@ func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetU
225224 }
226225
227226 func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
228- if pm == nil {
227+ if pm == nil || pm.namedContexts == nil {
229228 return
230229 }
231- pm.mu.Lock()
232- for key, state := range pm.namedPopups {
233- if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
234- delete(pm.namedPopups, key)
235- }
230+ pm.namedContexts.UnregisterByWebViewID(popupID)
231+}
232+
233+func (pm *popupManager) clearReusableNamedPopupByPaneID(paneID entity.PaneID) {
234+ if pm == nil || pm.namedContexts == nil {
235+ return
236236 }
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)
238245 }
239246
240247 func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
@@ -347,96 +354,109 @@ func (pm *popupManager) createPopupWebView(
347354 ctx context.Context,
348355 parentID port.WebViewID,
349356 targetURI string,
350- noJavaScriptAccess bool,
351-) (port.WebView, bool, error) {
357+ preparePaneHosted bool,
358+) (port.WebView, error) {
352359 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")
354361 }
355362
356- log := logging.FromContext(ctx)
357- var relatedErr error
358-
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)
363+ popupWV, err := pm.factory.CreateRelated(ctx, parentID)
364+ if err != nil {
365+ return nil, fmt.Errorf("create related popup webview for %s: %w", logging.TruncateURL(targetURI, logURLMaxLen), err)
405366 }
406367 if popupWV == nil {
407- if relatedErr != nil {
408- return nil, false, fmt.Errorf("create popup webview: related failed: %w; fallback returned nil", relatedErr)
368+ return nil, fmt.Errorf("related popup webview factory returned nil for %s", logging.TruncateURL(targetURI, logURLMaxLen))
369+ }
370+ if preparePaneHosted {
371+ if paneHosted, ok := popupWV.(port.PaneHostedBrowsingContextCapable); ok {
372+ paneHosted.PreparePaneHostedBrowsingContext()
409373 }
410- return nil, false, fmt.Errorf("create popup webview: fallback returned nil")
411374 }
412-
413- return popupWV, true, nil
375+ return popupWV, nil
414376 }
415377
416378 func (pm *popupManager) reuseNamedPopup(
417379 ctx context.Context,
380+ hooks popupCoordinatorHooks,
418381 parentPaneID entity.PaneID,
419382 frameName string,
420383 targetURI string,
421384 ) (port.WebView, bool) {
422385 log := logging.FromContext(ctx)
423386
424- if existing, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName); ok {
425- pm.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
426- if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
427- log.Warn().Err(err).
428- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
429- Msg("failed to load target URI in reused popup")
430- }
431- log.Info().
432- Str("parent_pane", string(parentPaneID)).
433- Str("frame_name", frameName).
387+ existingWV, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName, hooks)
388+ if !ok {
389+ return nil, false
390+ }
391+
392+ pm.updatePendingPopupTarget(existingWV.ID(), targetURI)
393+ if err := existingWV.LoadURI(ctx, targetURI); err != nil {
394+ log.Warn().Err(err).
434395 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
435- Msg("reused named popup")
436- return existing.WebView, true
396+ Msg("failed to load target URI in reused popup")
397+ }
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+ }
437420 }
421+ return parentURIAtOpen
422+}
438423
439- return nil, false
424+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
440460 }
441461
442462 func (pm *popupManager) handlePopupCreate(
@@ -466,31 +486,51 @@ func (pm *popupManager) handlePopupCreate(
466486 }
467487
468488 parentID := parentWV.ID()
469- parentURIAtOpen := ""
470- if pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
471- parentURIAtOpen = parentWV.URI()
472- if parentURIAtOpen == "" && hooks.getWebView != nil {
473- if parent := hooks.getWebView(parentPaneID); parent != nil {
474- parentURIAtOpen = parent.URI()
475- }
476- }
477- }
489+ parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
490+
491+ request := buildPopupBrowsingContextRequest(req)
492+ namedContextExists := false
478493 if !req.NoJavaScriptAccess {
479- if reused, ok := pm.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
480- return reused
481- }
494+ _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
482495 }
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")
483502
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)
485528 if err != nil {
486529 log.Error().Err(err).Msg("failed to create webview for popup")
487530 return nil
488531 }
489- if usedRegularFallback {
490- if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
491- openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
492- }
493- }
532+
533+ pm.setBrowsingContextDecision(popupWV, decision)
494534
495535 popupType := DetectPopupType(req.FrameName)
496536 popupID := popupWV.ID()
@@ -574,7 +614,7 @@ func (pm *popupManager) finishPopupCreate(
574614 lifecycle.PrimePopupNavigation(create.Request.TargetURI)
575615 }
576616 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)
578618 }
579619 if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
580620 if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
@@ -673,8 +713,7 @@ func (pm *popupManager) logPopupCloseSignal(ctx context.Context, hooks popupCoor
673713 if wv := hooks.getWebView(paneID); wv != nil {
674714 fields = fields.
675715 Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).
676- Bool("is_loading", wv.IsLoading()).
677- Bool("synthetic_opener_active", popupUsesSyntheticOpenerSignals(wv))
716+ Bool("is_loading", wv.IsLoading())
678717 }
679718 }
680719 }
@@ -761,12 +800,32 @@ func (pm *popupManager) handleLinkMiddleClick(
761800 log.Warn().Msg("no webview factory, cannot handle middle-click")
762801 return false
763802 }
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+ }
764812
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)
766824 if err != nil {
767825 log.Error().Err(err).Msg("failed to create webview for middle-click")
768826 return false
769827 }
828+ pm.setBrowsingContextDecision(newWV, decision)
770829
771830 var paneID entity.PaneID
772831 if pm.generatePaneID != nil {
@@ -786,14 +845,7 @@ func (pm *popupManager) handleLinkMiddleClick(
786845 hooks.setupWebViewCallbacks(ctx, paneID, newWV)
787846 }
788847
789- behavior := entity.PopupBehaviorStacked
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- }
848+ behavior, placement := popupTabInsertionConfig(pm.popupConfig)
797849
798850 if pm.onInsertPopup != nil {
799851 popupInput := InsertPopupInput{
M internal/ui/coordinator/content/popup_test.go +228 -118
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
13+ "github.com/bnema/dumber/internal/application/dto"
1314 "github.com/bnema/dumber/internal/application/port"
1415 "github.com/bnema/dumber/internal/application/port/mocks"
1516 "github.com/bnema/dumber/internal/domain/entity"
@@ -75,35 +76,35 @@ func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
7576 t.Parallel()
7677
7778 // BlankTargetBehavior is empty → falls through to default "stacked"
78- cfg := &entity.PopupBehaviorConfig{}
79+ cfg := &entity.BrowsingContextConfig{}
7980 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
8081 }
8182
8283 func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
8384 t.Parallel()
8485
85- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "split"}
86+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "split"}
8687 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
8788 }
8889
8990 func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
9091 t.Parallel()
9192
92- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}
93+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "stacked"}
9394 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
9495 }
9596
9697 func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
9798 t.Parallel()
9899
99- cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}
100+ cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "tabbed"}
100101 assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
101102 }
102103
103104 func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
104105 t.Parallel()
105106
106- cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorWindowed}
107+ cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorWindowed}
107108 assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
108109 }
109110
@@ -111,7 +112,7 @@ func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
111112 t.Parallel()
112113
113114 // Behavior zero-value is empty string; GetBehavior returns it as-is.
114- cfg := &entity.PopupBehaviorConfig{}
115+ cfg := &entity.BrowsingContextConfig{}
115116 assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
116117 }
117118
@@ -120,7 +121,7 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
120121
121122 // GetBehavior itself does not honor OpenInNewPane — that is enforced by
122123 // handlePopupCreate. GetBehavior still returns the configured value.
123- cfg := &entity.PopupBehaviorConfig{
124+ cfg := &entity.BrowsingContextConfig{
124125 OpenInNewPane: false,
125126 BlankTargetBehavior: "split",
126127 }
@@ -139,7 +140,7 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
139140 popupWV.EXPECT().ID().Return(popupID).Once()
140141
141142 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()
143144
144145 c := &Coordinator{
145146 webViews: make(map[entity.PaneID]port.WebView),
@@ -162,7 +163,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
162163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163164
164165 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()
166167
167168 c := &Coordinator{
168169 webViews: make(map[entity.PaneID]port.WebView),
@@ -230,7 +231,7 @@ func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t
230231 require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
231232 }
232233
233-func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
234+func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
234235 ctx := context.Background()
235236 parentPaneID := entity.PaneID("parent-pane")
236237 parentWV := mocks.NewMockWebView(t)
@@ -245,7 +246,7 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
245246 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
246247
247248 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()
249250
250251 c := &Coordinator{
251252 webViews: make(map[entity.PaneID]port.WebView),
@@ -267,74 +268,44 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
267268 require.Same(t, popupWV, created)
268269 }
269270
270-func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *testing.T) {
271+func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) {
271272 ctx := context.Background()
272273 parentPaneID := entity.PaneID("parent-pane")
273274 parentWV := mocks.NewMockWebView(t)
274- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
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()
275+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
291276
292277 factory := mocks.NewMockWebViewFactory(t)
293278 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()
296279
280+ insertCalls := 0
297281 c := &Coordinator{
298282 webViews: make(map[entity.PaneID]port.WebView),
299283 popups: newPopupManager(),
300284 }
301285 c.SetPopupConfig(factory, nil, nil)
302286 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
287+ insertCalls++
303288 return nil
304289 })
305290
306- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
307- TargetURI: "https://example.com/first",
308- FrameName: "_blank",
309- IsUserGesture: true,
310- })
311- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
312- TargetURI: "https://example.com/second",
291+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
292+ TargetURI: "https://example.com/edit",
313293 FrameName: "_blank",
314294 IsUserGesture: true,
315295 })
316296
317- require.Same(t, firstPopupWV, first)
318- require.Same(t, secondPopupWV, second)
297+ require.Nil(t, created)
298+ assert.Equal(t, 0, insertCalls)
319299 }
320300
321-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupported(t *testing.T) {
301+func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
322302 ctx := context.Background()
323303 parentPaneID := entity.PaneID("parent-pane")
324304 parentWV := mocks.NewMockWebView(t)
325305 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
326306
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-
335307 factory := mocks.NewMockWebViewFactory(t)
336- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
337- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
308+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
338309
339310 insertCalls := 0
340311 c := &Coordinator{
@@ -344,9 +315,6 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
344315 c.SetPopupConfig(factory, nil, nil)
345316 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
346317 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)
350318 return nil
351319 })
352320
@@ -356,90 +324,186 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
356324 IsUserGesture: true,
357325 })
358326
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)
360362 assert.Equal(t, 1, insertCalls)
363+ decision, hasDecision := newWV.BrowsingContextHostDecision()
364+ require.True(t, hasDecision)
365+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
361366 }
362367
363-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
368+func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
364369 ctx := context.Background()
365370 parentPaneID := entity.PaneID("parent-pane")
366371 parentWV := mocks.NewMockWebView(t)
367372 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
373+ parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once()
368374
369- popupWV := mocks.NewMockWebView(t)
370- popupWV.EXPECT().ID().Return(port.WebViewID(204)).Once()
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()
375+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
376+ popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe()
376377
377378 factory := mocks.NewMockWebViewFactory(t)
378- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
379- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
379+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
380380
381381 insertCalls := 0
382+ nativeCalls := 0
382383 c := &Coordinator{
383384 webViews: make(map[entity.PaneID]port.WebView),
384385 popups: newPopupManager(),
385386 }
386- c.SetPopupConfig(factory, nil, nil)
387- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
387+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil)
388+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
388389 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)
390398 return nil
391399 })
392400
393401 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
394- TargetURI: "https://example.com/edit",
395- FrameName: "_blank",
402+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
403+ FrameName: "oauth-popup",
396404 IsUserGesture: true,
397405 })
398406
399- require.Same(t, popupWV, created)
400- assert.Equal(t, 1, insertCalls)
407+ require.Equal(t, popupWV, created)
408+ assert.Equal(t, 0, insertCalls)
409+ assert.Equal(t, 1, nativeCalls)
410+ assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed)
401411 }
402412
403-func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported(t *testing.T) {
413+func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) {
404414 ctx := context.Background()
405415 parentPaneID := entity.PaneID("parent-pane")
406416 parentWV := mocks.NewMockWebView(t)
407417 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
408418
409- popupWV := &popupOpenerBridgeWebViewStub{MockWebView: mocks.NewMockWebView(t)}
410- popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
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()
419+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
420+ popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe()
416421
417422 factory := mocks.NewMockWebViewFactory(t)
418- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
419- factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
423+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
420424
421425 insertCalls := 0
426+ nativeCalls := 0
422427 c := &Coordinator{
423428 webViews: make(map[entity.PaneID]port.WebView),
424429 popups: newPopupManager(),
425430 }
426- c.SetPopupConfig(factory, nil, nil)
427- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
431+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
432+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
428433 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)
430439 return nil
431440 })
432441
433442 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
434- TargetURI: "https://example.com/popup?redirect=%2Fcallback",
435- FrameName: "auth-popup",
443+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
444+ FrameName: "oauth-popup",
436445 IsUserGesture: true,
437446 })
438447
439- require.Same(t, popupWV, created)
440- assert.Equal(t, 1, insertCalls)
441- assert.Same(t, parentWV, popupWV.parent)
442- assert.False(t, popupWV.noJavaScriptAccess)
448+ require.Equal(t, popupWV, created)
449+ assert.Equal(t, 1, nativeCalls)
450+ assert.Equal(t, 0, insertCalls)
451+}
452+
453+func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
454+ ctx := context.Background()
455+ parentPaneID := entity.PaneID("parent-pane")
456+ parentWV := mocks.NewMockWebView(t)
457+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
458+
459+ firstWV := mocks.NewMockWebView(t)
460+ firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe()
461+ firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
462+ firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
463+ firstWV.EXPECT().IsLoading().Return(false).Maybe()
464+ firstWV.EXPECT().URI().Return("").Maybe()
465+ firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe()
466+
467+ secondWV := mocks.NewMockWebView(t)
468+ secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe()
469+ secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
470+ secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
471+ secondWV.EXPECT().IsLoading().Return(false).Maybe()
472+ secondWV.EXPECT().URI().Return("").Maybe()
473+ secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe()
474+
475+ factory := mocks.NewMockWebViewFactory(t)
476+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
477+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
478+
479+ insertCalls := 0
480+ c := &Coordinator{
481+ webViews: make(map[entity.PaneID]port.WebView),
482+ popups: newPopupManager(),
483+ }
484+ c.SetPopupConfig(factory, nil, nil)
485+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
486+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
487+ insertCalls++
488+ return nil
489+ })
490+
491+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
492+ TargetURI: "https://example.com/first",
493+ FrameName: "shared-pane",
494+ IsUserGesture: true,
495+ NoJavaScriptAccess: true,
496+ })
497+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
498+ TargetURI: "https://example.com/second",
499+ FrameName: "shared-pane",
500+ IsUserGesture: true,
501+ NoJavaScriptAccess: true,
502+ })
503+
504+ require.Same(t, firstWV, first)
505+ require.Same(t, secondWV, second)
506+ assert.Equal(t, 2, insertCalls)
443507 }
444508
445509 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
@@ -449,19 +513,19 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
449513 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
450514
451515 popupWV := mocks.NewMockWebView(t)
452- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Twice()
516+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
453517 popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
454518 popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
455519 popupWV.EXPECT().IsLoading().Return(false).Maybe()
456520 currentURI := ""
457521 popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
458522 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 {
460- currentURI = "https://accounts.google.com/o/oauth2/v2/auth?first"
523+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error {
524+ currentURI = "https://example.com/first"
461525 return nil
462526 }).Once()
463- popupWV.EXPECT().LoadURI(mock.Anything, "https://accounts.google.com/o/oauth2/v2/auth?second").RunAndReturn(func(context.Context, string) error {
464- currentURI = "https://accounts.google.com/o/oauth2/v2/auth?second"
527+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error {
528+ currentURI = "https://example.com/second"
465529 return nil
466530 }).Once()
467531
@@ -474,27 +538,78 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
474538 popups: newPopupManager(),
475539 }
476540 c.SetPopupConfig(factory, nil, nil)
541+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
477542 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
478543 insertCalls++
479544 return nil
480545 })
481546
482547 first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
483- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?first",
484- FrameName: "g_credential_picker_x",
548+ TargetURI: "https://example.com/first",
549+ FrameName: "shared-pane",
485550 IsUserGesture: true,
486551 })
487552 require.Same(t, popupWV, first)
488553
489554 second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
490- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?second",
491- FrameName: "g_credential_picker_x",
555+ TargetURI: "https://example.com/second",
556+ FrameName: "shared-pane",
492557 IsUserGesture: true,
493558 })
494559 require.Same(t, popupWV, second)
495560
496561 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)
498613 }
499614
500615 // ---------------------------------------------------------------------------
@@ -598,7 +713,7 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
598713 c := &Coordinator{}
599714
600715 factory := &stubFactory{}
601- cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorSplit}
716+ cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
602717 genID := func() string { return "test-id" }
603718
604719 c.SetPopupConfig(factory, cfg, genID)
@@ -622,32 +737,27 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
622737
623738 type popupNavigationWebViewStub struct {
624739 *mocks.MockWebView
625- primed []string
740+ primed []string
741+ browsingContextDecision dto.HostDecision
742+ hasBrowsingContextDecision bool
626743 }
627744
628745 func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
629746 s.primed = append(s.primed, uri)
630747 }
631748
632-func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
633-func (*popupNavigationWebViewStub) SetOnClose(func()) {}
634-func (*popupNavigationWebViewStub) Show() {}
635-
636-type popupOpenerBridgeWebViewStub struct {
637- *mocks.MockWebView
638- parent port.WebView
639- noJavaScriptAccess bool
749+func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
750+ s.browsingContextDecision = decision
751+ s.hasBrowsingContextDecision = true
640752 }
641753
642-func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
643- s.parent = parent
644- s.noJavaScriptAccess = noJavaScriptAccess
754+func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
755+ return s.browsingContextDecision, s.hasBrowsingContextDecision
645756 }
646757
647-func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {}
648-func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) {
649-}
650-func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
758+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
759+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
760+func (*popupNavigationWebViewStub) Show() {}
651761
652762 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
653763 type stubFactory struct{}
M internal/ui/input/global_shortcuts.go +22 -1
@@ -399,9 +399,14 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
399399 }
400400
401401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
402- if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
402+ if h == nil {
403403 return false
404404 }
405+ if !isRepeatedGlobalShortcutSuppressed(action) {
406+ if _, ok := ParseFloatingProfileTarget(action); !ok {
407+ return false
408+ }
409+ }
405410 if h.lastDispatchAt == nil {
406411 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
407412 // rather than letting a stale GTK callback repeat destructive actions.
@@ -419,6 +424,22 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
419424 case ActionGoBack,
420425 ActionGoForward,
421426 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,
422443 ActionClosePane,
423444 ActionCloseTab,
424445 ActionQuit,
M internal/ui/input/global_shortcuts_test.go +48 -0
@@ -120,6 +120,54 @@ func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
120120 }
121121 }
122122
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+
123171 func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
124172 if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
125173 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