detached 7e12a7d

fix(cef): preserve popup flows without related webviews

commit
7e12a7d2db5d5e1c2bafb3b9d937b8048d3d3416
author
brice · · 2026-04-21 12:06 UTC
parent
2921b0b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +959 -11
M internal/infrastructure/cef/content_injector.go +145 -2
@@ -617,10 +617,152 @@ const clipboardSelectionFetchBridgeJS = `(function(){
617617 if (isEditable(document.activeElement)) sendFocusSync();
618618 })();`
619619
620+const popupFetchBridgeJS = `(function() {
621+ if (window.__dumberPopupBridgePatched) return;
622+ window.__dumberPopupBridgePatched = true;
623+
624+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
625+
626+ function encodeBody(body) {
627+ try {
628+ return btoa(unescape(encodeURIComponent(body)));
629+ } catch (_) {
630+ return '';
631+ }
632+ }
633+
634+ function postBridge(url, payload) {
635+ var encoded = encodeBody(JSON.stringify(payload));
636+ if (!encoded) return;
637+ fetch(url, {
638+ method: 'POST',
639+ headers: {
640+ 'X-Dumber-Body': encoded,
641+ 'X-Dumber-Bridge-Nonce': bridgeNonce
642+ }
643+ }).catch(function() {});
644+ }
645+
646+ function resolvePopupURL(rawURL) {
647+ if (rawURL == null || rawURL === '') return '';
648+ try {
649+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
650+ } catch (_) {
651+ return String(rawURL);
652+ }
653+ }
654+
655+ function hasUserGesture() {
656+ try {
657+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
658+ } catch (_) {
659+ return false;
660+ }
661+ }
662+
663+ function popupHasNoOpener(features) {
664+ if (features == null || features === '') return false;
665+ var normalized = String(features).toLowerCase();
666+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
667+ }
668+
669+ function shouldDelegateWindowOpen(target) {
670+ return target === '_self' || target === '_top' || target === '_parent';
671+ }
672+
673+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
674+ var href = initialURL || 'about:blank';
675+ var closed = false;
676+
677+ function navigate(nextURL) {
678+ href = resolvePopupURL(nextURL);
679+ postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: href });
680+ return href;
681+ }
682+
683+ var locationProxy = {
684+ assign: function(nextURL) { return navigate(nextURL); },
685+ replace: function(nextURL) { return navigate(nextURL); },
686+ toString: function() { return href; }
687+ };
688+ try {
689+ Object.defineProperty(locationProxy, 'href', {
690+ configurable: true,
691+ enumerable: true,
692+ get: function() { return href; },
693+ set: function(nextURL) { navigate(nextURL); }
694+ });
695+ } catch (_) {
696+ locationProxy.href = href;
697+ }
698+
699+ var proxy = {
700+ blur: function() { return undefined; },
701+ close: function() { closed = true; },
702+ focus: function() { return undefined; },
703+ postMessage: function() { return undefined; }
704+ };
705+ proxy.opener = popupHasNoOpener(features) ? null : window;
706+ proxy.self = proxy;
707+ proxy.window = proxy;
708+
709+ try {
710+ Object.defineProperty(proxy, 'closed', {
711+ configurable: true,
712+ enumerable: true,
713+ get: function() { return closed; },
714+ set: function(next) { closed = !!next; }
715+ });
716+ } catch (_) {
717+ proxy.closed = false;
718+ }
719+
720+ try {
721+ Object.defineProperty(proxy, 'location', {
722+ configurable: true,
723+ enumerable: true,
724+ get: function() { return locationProxy; },
725+ set: function(nextURL) { navigate(nextURL); }
726+ });
727+ } catch (_) {
728+ proxy.location = locationProxy;
729+ }
730+
731+ return proxy;
732+ }
733+
734+ try {
735+ var originalWindowOpen = typeof window.open === 'function' ? window.open.bind(window) : null;
736+ window.open = function(url, target, features) {
737+ var normalizedTarget = target == null ? '' : String(target);
738+ if (originalWindowOpen && shouldDelegateWindowOpen(normalizedTarget)) {
739+ return originalWindowOpen.apply(window, arguments);
740+ }
741+
742+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
743+ var resolvedURL = resolvePopupURL(url);
744+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
745+ postBridge('dumb:///api/popup-open', {
746+ proxy_id: proxyID,
747+ url: resolvedURL,
748+ frame_name: normalizedTarget,
749+ user_gesture: hasUserGesture()
750+ });
751+ return popupProxy;
752+ };
753+ } catch (_) {
754+ window.__dumberPopupBridgePatched = false;
755+ }
756+})();`
757+
620758 func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
621759 return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
622760 }
623761
762+func buildPopupFetchBridgeJS(bridgeNonce string) string {
763+ return strings.ReplaceAll(popupFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
764+}
765+
624766 // contentInjector implements port.ContentInjector for the CEF engine.
625767 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
626768 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -741,8 +883,8 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
741883 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
742884 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
743885
744- // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
745- // mode while the native renderer bridge remains disabled.
886+ // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
887+ // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
746888 bridgeNonce := wv.rotateBridgeNonce()
747889 if bridgeNonce == "" {
748890 ctx := context.Background()
@@ -752,6 +894,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
752894 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753895 } else {
754896 wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
897+ wv.RunJavaScript(context.Background(), buildPopupFetchBridgeJS(bridgeNonce))
755898 }
756899
757900 // Video playback diagnostic — logs video element state changes.
@@ -617,10 +617,152 @@ const clipboardSelectionFetchBridgeJS = `(function(){
617 if (isEditable(document.activeElement)) sendFocusSync();617 if (isEditable(document.activeElement)) sendFocusSync();
618 })();`618 })();`
619 619
620+const popupFetchBridgeJS = `(function() {
621+ if (window.__dumberPopupBridgePatched) return;
622+ window.__dumberPopupBridgePatched = true;
623+
624+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
625+
626+ function encodeBody(body) {
627+ try {
628+ return btoa(unescape(encodeURIComponent(body)));
629+ } catch (_) {
630+ return '';
631+ }
632+ }
633+
634+ function postBridge(url, payload) {
635+ var encoded = encodeBody(JSON.stringify(payload));
636+ if (!encoded) return;
637+ fetch(url, {
638+ method: 'POST',
639+ headers: {
640+ 'X-Dumber-Body': encoded,
641+ 'X-Dumber-Bridge-Nonce': bridgeNonce
642+ }
643+ }).catch(function() {});
644+ }
645+
646+ function resolvePopupURL(rawURL) {
647+ if (rawURL == null || rawURL === '') return '';
648+ try {
649+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
650+ } catch (_) {
651+ return String(rawURL);
652+ }
653+ }
654+
655+ function hasUserGesture() {
656+ try {
657+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
658+ } catch (_) {
659+ return false;
660+ }
661+ }
662+
663+ function popupHasNoOpener(features) {
664+ if (features == null || features === '') return false;
665+ var normalized = String(features).toLowerCase();
666+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
667+ }
668+
669+ function shouldDelegateWindowOpen(target) {
670+ return target === '_self' || target === '_top' || target === '_parent';
671+ }
672+
673+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
674+ var href = initialURL || 'about:blank';
675+ var closed = false;
676+
677+ function navigate(nextURL) {
678+ href = resolvePopupURL(nextURL);
679+ postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: href });
680+ return href;
681+ }
682+
683+ var locationProxy = {
684+ assign: function(nextURL) { return navigate(nextURL); },
685+ replace: function(nextURL) { return navigate(nextURL); },
686+ toString: function() { return href; }
687+ };
688+ try {
689+ Object.defineProperty(locationProxy, 'href', {
690+ configurable: true,
691+ enumerable: true,
692+ get: function() { return href; },
693+ set: function(nextURL) { navigate(nextURL); }
694+ });
695+ } catch (_) {
696+ locationProxy.href = href;
697+ }
698+
699+ var proxy = {
700+ blur: function() { return undefined; },
701+ close: function() { closed = true; },
702+ focus: function() { return undefined; },
703+ postMessage: function() { return undefined; }
704+ };
705+ proxy.opener = popupHasNoOpener(features) ? null : window;
706+ proxy.self = proxy;
707+ proxy.window = proxy;
708+
709+ try {
710+ Object.defineProperty(proxy, 'closed', {
711+ configurable: true,
712+ enumerable: true,
713+ get: function() { return closed; },
714+ set: function(next) { closed = !!next; }
715+ });
716+ } catch (_) {
717+ proxy.closed = false;
718+ }
719+
720+ try {
721+ Object.defineProperty(proxy, 'location', {
722+ configurable: true,
723+ enumerable: true,
724+ get: function() { return locationProxy; },
725+ set: function(nextURL) { navigate(nextURL); }
726+ });
727+ } catch (_) {
728+ proxy.location = locationProxy;
729+ }
730+
731+ return proxy;
732+ }
733+
734+ try {
735+ var originalWindowOpen = typeof window.open === 'function' ? window.open.bind(window) : null;
736+ window.open = function(url, target, features) {
737+ var normalizedTarget = target == null ? '' : String(target);
738+ if (originalWindowOpen && shouldDelegateWindowOpen(normalizedTarget)) {
739+ return originalWindowOpen.apply(window, arguments);
740+ }
741+
742+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
743+ var resolvedURL = resolvePopupURL(url);
744+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
745+ postBridge('dumb:///api/popup-open', {
746+ proxy_id: proxyID,
747+ url: resolvedURL,
748+ frame_name: normalizedTarget,
749+ user_gesture: hasUserGesture()
750+ });
751+ return popupProxy;
752+ };
753+ } catch (_) {
754+ window.__dumberPopupBridgePatched = false;
755+ }
756+})();`
757+
620 func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {758 func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
621 return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)759 return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
622 }760 }
623 761
762+func buildPopupFetchBridgeJS(bridgeNonce string) string {
763+ return strings.ReplaceAll(popupFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
764+}
765+
624 // contentInjector implements port.ContentInjector for the CEF engine.766 // contentInjector implements port.ContentInjector for the CEF engine.
625 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.767 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
626 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd768 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -741,8 +883,8 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
741 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)883 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
742 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)884 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
743 885
744- // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR886+ // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
745- // mode while the native renderer bridge remains disabled.887+ // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
746 bridgeNonce := wv.rotateBridgeNonce()888 bridgeNonce := wv.rotateBridgeNonce()
747 if bridgeNonce == "" {889 if bridgeNonce == "" {
748 ctx := context.Background()890 ctx := context.Background()
@@ -752,6 +894,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
752 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")894 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753 } else {895 } else {
754 wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))896 wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
897+ wv.RunJavaScript(context.Background(), buildPopupFetchBridgeJS(bridgeNonce))
755 }898 }
756 899
757 // Video playback diagnostic — logs video element state changes.900 // Video playback diagnostic — logs video element state changes.
M internal/infrastructure/cef/content_injector_test.go +18 -0
@@ -10,6 +10,10 @@ func testClipboardSelectionFetchBridgeJS() string {
1010 return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
1111 }
1212
13+func testPopupFetchBridgeJS() string {
14+ return buildPopupFetchBridgeJS("test-bridge-nonce")
15+}
16+
1317 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
1418 script := testClipboardSelectionFetchBridgeJS()
1519
@@ -61,3 +65,17 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
6165 require.NotContains(t, script, "setBridgeNonce")
6266 require.Contains(t, script, "test-bridge-nonce")
6367 }
68+
69+func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
70+ script := testPopupFetchBridgeJS()
71+
72+ require.Contains(t, script, "window.__dumberPopupBridgePatched")
73+ require.Contains(t, script, "window.open = function(url, target, features)")
74+ require.Contains(t, script, "dumb:///api/popup-open")
75+ require.Contains(t, script, "dumb:///api/popup-navigate")
76+ require.Contains(t, script, "proxy_id")
77+ require.Contains(t, script, "createSyntheticPopupProxy")
78+ require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
79+ require.Contains(t, script, "test-bridge-nonce")
80+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
81+}
@@ -10,6 +10,10 @@ func testClipboardSelectionFetchBridgeJS() string {
10 return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")10 return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
11 }11 }
12 12
13+func testPopupFetchBridgeJS() string {
14+ return buildPopupFetchBridgeJS("test-bridge-nonce")
15+}
16+
13 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {17 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
14 script := testClipboardSelectionFetchBridgeJS()18 script := testClipboardSelectionFetchBridgeJS()
15 19
@@ -61,3 +65,17 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
61 require.NotContains(t, script, "setBridgeNonce")65 require.NotContains(t, script, "setBridgeNonce")
62 require.Contains(t, script, "test-bridge-nonce")66 require.Contains(t, script, "test-bridge-nonce")
63 }67 }
68+
69+func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
70+ script := testPopupFetchBridgeJS()
71+
72+ require.Contains(t, script, "window.__dumberPopupBridgePatched")
73+ require.Contains(t, script, "window.open = function(url, target, features)")
74+ require.Contains(t, script, "dumb:///api/popup-open")
75+ require.Contains(t, script, "dumb:///api/popup-navigate")
76+ require.Contains(t, script, "proxy_id")
77+ require.Contains(t, script, "createSyntheticPopupProxy")
78+ require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
79+ require.Contains(t, script, "test-bridge-nonce")
80+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
81+}
M internal/infrastructure/cef/engine_init.go +2 -0
@@ -258,6 +258,8 @@ func wireEngine(
258258 }
259259
260260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261+ schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262+ schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
261263 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
262264
263265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -258,6 +258,8 @@ func wireEngine(
258 }258 }
259 259
260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261+ schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262+ schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
261 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest263 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
262 264
263 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
A internal/infrastructure/cef/engine_popup_bridge.go +61 -0
@@ -0,0 +1,61 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
10+ if e == nil || browser == nil {
11+ return nil
12+ }
13+
14+ browserID := browser.GetIdentifier()
15+ var matched *WebView
16+ e.activeWebViews.Range(func(_, value any) bool {
17+ wv, ok := value.(*WebView)
18+ if !ok || wv == nil {
19+ return true
20+ }
21+ wv.mu.RLock()
22+ wvBrowser := wv.browser
23+ wv.mu.RUnlock()
24+ if wvBrowser == nil || wvBrowser.GetIdentifier() != browserID {
25+ return true
26+ }
27+ matched = wv
28+ return false
29+ })
30+ return matched
31+}
32+
33+func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
34+ if e == nil {
35+ return
36+ }
37+ wv := e.webViewForBrowser(browser)
38+ if wv == nil {
39+ logging.FromContext(e.currentContext()).Warn().
40+ Int32("browser_id", browser.GetIdentifier()).
41+ Str("proxy_id", payload.ProxyID).
42+ Msg("cef: popup-open bridge could not resolve source webview")
43+ return
44+ }
45+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture)
46+}
47+
48+func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
49+ if e == nil {
50+ return
51+ }
52+ wv := e.webViewForBrowser(browser)
53+ if wv == nil {
54+ logging.FromContext(e.currentContext()).Warn().
55+ Int32("browser_id", browser.GetIdentifier()).
56+ Str("proxy_id", payload.ProxyID).
57+ Msg("cef: popup-navigate bridge could not resolve source webview")
58+ return
59+ }
60+ wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
61+}
@@ -0,0 +1,61 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
10+ if e == nil || browser == nil {
11+ return nil
12+ }
13+
14+ browserID := browser.GetIdentifier()
15+ var matched *WebView
16+ e.activeWebViews.Range(func(_, value any) bool {
17+ wv, ok := value.(*WebView)
18+ if !ok || wv == nil {
19+ return true
20+ }
21+ wv.mu.RLock()
22+ wvBrowser := wv.browser
23+ wv.mu.RUnlock()
24+ if wvBrowser == nil || wvBrowser.GetIdentifier() != browserID {
25+ return true
26+ }
27+ matched = wv
28+ return false
29+ })
30+ return matched
31+}
32+
33+func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
34+ if e == nil {
35+ return
36+ }
37+ wv := e.webViewForBrowser(browser)
38+ if wv == nil {
39+ logging.FromContext(e.currentContext()).Warn().
40+ Int32("browser_id", browser.GetIdentifier()).
41+ Str("proxy_id", payload.ProxyID).
42+ Msg("cef: popup-open bridge could not resolve source webview")
43+ return
44+ }
45+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture)
46+}
47+
48+func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
49+ if e == nil {
50+ return
51+ }
52+ wv := e.webViewForBrowser(browser)
53+ if wv == nil {
54+ logging.FromContext(e.currentContext()).Warn().
55+ Int32("browser_id", browser.GetIdentifier()).
56+ Str("proxy_id", payload.ProxyID).
57+ Msg("cef: popup-navigate bridge could not resolve source webview")
58+ return
59+ }
60+ wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
61+}
M internal/infrastructure/cef/renderer_bridge.go +159 -0
@@ -17,6 +17,8 @@ const (
1717 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
1818 rendererBridgeActionFocusSync = "focus_sync"
1919 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionPopupOpen = "popup_open"
21+ rendererBridgeActionPopupNavigate = "popup_navigate"
2022 rendererBridgeActionReady = "bridge_ready"
2123 rendererBridgeExtensionName = "dumber.renderer_bridge"
2224 )
@@ -65,6 +67,123 @@ const rendererBridgeExtensionJS = `
6567 return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
6668 }
6769
70+ function resolvePopupURL(rawURL) {
71+ if (rawURL == null || rawURL === '') return '';
72+ try {
73+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
74+ } catch (_) {
75+ return String(rawURL);
76+ }
77+ }
78+
79+ function hasUserGesture() {
80+ try {
81+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
82+ } catch (_) {
83+ return false;
84+ }
85+ }
86+
87+ function popupHasNoOpener(features) {
88+ if (features == null || features === '') return false;
89+ var normalized = String(features).toLowerCase();
90+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
91+ }
92+
93+ function shouldDelegateWindowOpen(target) {
94+ return target === '_self' || target === '_top' || target === '_parent';
95+ }
96+
97+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
98+ var href = initialURL || 'about:blank';
99+ var closed = false;
100+
101+ function navigate(nextURL) {
102+ href = resolvePopupURL(nextURL);
103+ send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }));
104+ return href;
105+ }
106+
107+ var locationProxy = {
108+ assign: function(nextURL) { return navigate(nextURL); },
109+ replace: function(nextURL) { return navigate(nextURL); },
110+ toString: function() { return href; }
111+ };
112+ try {
113+ Object.defineProperty(locationProxy, 'href', {
114+ configurable: true,
115+ enumerable: true,
116+ get: function() { return href; },
117+ set: function(nextURL) { navigate(nextURL); }
118+ });
119+ } catch (_) {
120+ locationProxy.href = href;
121+ }
122+
123+ var proxy = {
124+ blur: function() { return undefined; },
125+ close: function() { closed = true; },
126+ focus: function() { return undefined; },
127+ postMessage: function() { return undefined; }
128+ };
129+ proxy.opener = popupHasNoOpener(features) ? null : window;
130+ proxy.self = proxy;
131+ proxy.window = proxy;
132+
133+ try {
134+ Object.defineProperty(proxy, 'closed', {
135+ configurable: true,
136+ enumerable: true,
137+ get: function() { return closed; },
138+ set: function(next) { closed = !!next; }
139+ });
140+ } catch (_) {
141+ proxy.closed = false;
142+ }
143+
144+ try {
145+ Object.defineProperty(proxy, 'location', {
146+ configurable: true,
147+ enumerable: true,
148+ get: function() { return locationProxy; },
149+ set: function(nextURL) { navigate(nextURL); }
150+ });
151+ } catch (_) {
152+ proxy.location = locationProxy;
153+ }
154+
155+ try {
156+ Object.defineProperty(proxy, Symbol.toStringTag, {
157+ configurable: true,
158+ value: 'Window'
159+ });
160+ } catch (_) {}
161+
162+ return proxy;
163+ }
164+
165+ if (typeof window !== 'undefined' && typeof window.open === 'function' && !window.__dumberPopupOpenPatched) {
166+ window.__dumberPopupOpenPatched = true;
167+ var originalWindowOpen = window.open;
168+ window.open = function(url, target, features) {
169+ var normalizedTarget = target == null ? '' : String(target);
170+ if (shouldDelegateWindowOpen(normalizedTarget)) {
171+ return originalWindowOpen.apply(this, arguments);
172+ }
173+
174+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
175+ var resolvedURL = resolvePopupURL(url);
176+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
177+ send('popup_open', JSON.stringify({
178+ proxy_id: proxyID,
179+ url: resolvedURL,
180+ frame_name: normalizedTarget,
181+ user_gesture: hasUserGesture()
182+ }));
183+ return popupProxy;
184+ };
185+ }
186+
68187 if (typeof document !== 'undefined' && document.addEventListener) {
69188 document.addEventListener('focusin', function(event) {
70189 if (isEditable(event.target)) {
@@ -338,6 +457,18 @@ type rendererBridgeExplicitTextCopyPayload struct {
338457 Action string `json:"action"`
339458 }
340459
460+type rendererBridgePopupOpenPayload struct {
461+ ProxyID string `json:"proxy_id"`
462+ URL string `json:"url"`
463+ FrameName string `json:"frame_name"`
464+ UserGesture bool `json:"user_gesture"`
465+}
466+
467+type rendererBridgePopupNavigatePayload struct {
468+ ProxyID string `json:"proxy_id"`
469+ URL string `json:"url"`
470+}
471+
341472 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
342473 var req rendererBridgeExplicitTextCopyPayload
343474 if len(payload) == 0 {
@@ -355,6 +486,34 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
355486 return req, nil
356487 }
357488
489+func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
490+ var req rendererBridgePopupOpenPayload
491+ if len(payload) == 0 {
492+ return req, fmt.Errorf("empty payload")
493+ }
494+ if err := json.Unmarshal(payload, &req); err != nil {
495+ return req, err
496+ }
497+ if req.ProxyID == "" {
498+ return req, fmt.Errorf("missing proxy_id")
499+ }
500+ return req, nil
501+}
502+
503+func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {
504+ var req rendererBridgePopupNavigatePayload
505+ if len(payload) == 0 {
506+ return req, fmt.Errorf("empty payload")
507+ }
508+ if err := json.Unmarshal(payload, &req); err != nil {
509+ return req, err
510+ }
511+ if req.ProxyID == "" {
512+ return req, fmt.Errorf("missing proxy_id")
513+ }
514+ return req, nil
515+}
516+
358517 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
359518 logger := logging.FromContext(e.currentContext())
360519 task := purecef.NewTask(cefTaskFunc(func() {
@@ -17,6 +17,8 @@ const (
17 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"17 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18 rendererBridgeActionFocusSync = "focus_sync"18 rendererBridgeActionFocusSync = "focus_sync"
19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionPopupOpen = "popup_open"
21+ rendererBridgeActionPopupNavigate = "popup_navigate"
20 rendererBridgeActionReady = "bridge_ready"22 rendererBridgeActionReady = "bridge_ready"
21 rendererBridgeExtensionName = "dumber.renderer_bridge"23 rendererBridgeExtensionName = "dumber.renderer_bridge"
22 )24 )
@@ -65,6 +67,123 @@ const rendererBridgeExtensionJS = `
65 return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));67 return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
66 }68 }
67 69
70+ function resolvePopupURL(rawURL) {
71+ if (rawURL == null || rawURL === '') return '';
72+ try {
73+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
74+ } catch (_) {
75+ return String(rawURL);
76+ }
77+ }
78+
79+ function hasUserGesture() {
80+ try {
81+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
82+ } catch (_) {
83+ return false;
84+ }
85+ }
86+
87+ function popupHasNoOpener(features) {
88+ if (features == null || features === '') return false;
89+ var normalized = String(features).toLowerCase();
90+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
91+ }
92+
93+ function shouldDelegateWindowOpen(target) {
94+ return target === '_self' || target === '_top' || target === '_parent';
95+ }
96+
97+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
98+ var href = initialURL || 'about:blank';
99+ var closed = false;
100+
101+ function navigate(nextURL) {
102+ href = resolvePopupURL(nextURL);
103+ send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }));
104+ return href;
105+ }
106+
107+ var locationProxy = {
108+ assign: function(nextURL) { return navigate(nextURL); },
109+ replace: function(nextURL) { return navigate(nextURL); },
110+ toString: function() { return href; }
111+ };
112+ try {
113+ Object.defineProperty(locationProxy, 'href', {
114+ configurable: true,
115+ enumerable: true,
116+ get: function() { return href; },
117+ set: function(nextURL) { navigate(nextURL); }
118+ });
119+ } catch (_) {
120+ locationProxy.href = href;
121+ }
122+
123+ var proxy = {
124+ blur: function() { return undefined; },
125+ close: function() { closed = true; },
126+ focus: function() { return undefined; },
127+ postMessage: function() { return undefined; }
128+ };
129+ proxy.opener = popupHasNoOpener(features) ? null : window;
130+ proxy.self = proxy;
131+ proxy.window = proxy;
132+
133+ try {
134+ Object.defineProperty(proxy, 'closed', {
135+ configurable: true,
136+ enumerable: true,
137+ get: function() { return closed; },
138+ set: function(next) { closed = !!next; }
139+ });
140+ } catch (_) {
141+ proxy.closed = false;
142+ }
143+
144+ try {
145+ Object.defineProperty(proxy, 'location', {
146+ configurable: true,
147+ enumerable: true,
148+ get: function() { return locationProxy; },
149+ set: function(nextURL) { navigate(nextURL); }
150+ });
151+ } catch (_) {
152+ proxy.location = locationProxy;
153+ }
154+
155+ try {
156+ Object.defineProperty(proxy, Symbol.toStringTag, {
157+ configurable: true,
158+ value: 'Window'
159+ });
160+ } catch (_) {}
161+
162+ return proxy;
163+ }
164+
165+ if (typeof window !== 'undefined' && typeof window.open === 'function' && !window.__dumberPopupOpenPatched) {
166+ window.__dumberPopupOpenPatched = true;
167+ var originalWindowOpen = window.open;
168+ window.open = function(url, target, features) {
169+ var normalizedTarget = target == null ? '' : String(target);
170+ if (shouldDelegateWindowOpen(normalizedTarget)) {
171+ return originalWindowOpen.apply(this, arguments);
172+ }
173+
174+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
175+ var resolvedURL = resolvePopupURL(url);
176+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
177+ send('popup_open', JSON.stringify({
178+ proxy_id: proxyID,
179+ url: resolvedURL,
180+ frame_name: normalizedTarget,
181+ user_gesture: hasUserGesture()
182+ }));
183+ return popupProxy;
184+ };
185+ }
186+
68 if (typeof document !== 'undefined' && document.addEventListener) {187 if (typeof document !== 'undefined' && document.addEventListener) {
69 document.addEventListener('focusin', function(event) {188 document.addEventListener('focusin', function(event) {
70 if (isEditable(event.target)) {189 if (isEditable(event.target)) {
@@ -338,6 +457,18 @@ type rendererBridgeExplicitTextCopyPayload struct {
338 Action string `json:"action"`457 Action string `json:"action"`
339 }458 }
340 459
460+type rendererBridgePopupOpenPayload struct {
461+ ProxyID string `json:"proxy_id"`
462+ URL string `json:"url"`
463+ FrameName string `json:"frame_name"`
464+ UserGesture bool `json:"user_gesture"`
465+}
466+
467+type rendererBridgePopupNavigatePayload struct {
468+ ProxyID string `json:"proxy_id"`
469+ URL string `json:"url"`
470+}
471+
341 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {472 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
342 var req rendererBridgeExplicitTextCopyPayload473 var req rendererBridgeExplicitTextCopyPayload
343 if len(payload) == 0 {474 if len(payload) == 0 {
@@ -355,6 +486,34 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
355 return req, nil486 return req, nil
356 }487 }
357 488
489+func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
490+ var req rendererBridgePopupOpenPayload
491+ if len(payload) == 0 {
492+ return req, fmt.Errorf("empty payload")
493+ }
494+ if err := json.Unmarshal(payload, &req); err != nil {
495+ return req, err
496+ }
497+ if req.ProxyID == "" {
498+ return req, fmt.Errorf("missing proxy_id")
499+ }
500+ return req, nil
501+}
502+
503+func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {
504+ var req rendererBridgePopupNavigatePayload
505+ if len(payload) == 0 {
506+ return req, fmt.Errorf("empty payload")
507+ }
508+ if err := json.Unmarshal(payload, &req); err != nil {
509+ return req, err
510+ }
511+ if req.ProxyID == "" {
512+ return req, fmt.Errorf("missing proxy_id")
513+ }
514+ return req, nil
515+}
516+
358 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {517 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
359 logger := logging.FromContext(e.currentContext())518 logger := logging.FromContext(e.currentContext())
360 task := purecef.NewTask(cefTaskFunc(func() {519 task := purecef.NewTask(cefTaskFunc(func() {
M internal/infrastructure/cef/renderer_bridge_test.go +24 -0
@@ -97,6 +97,14 @@ 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, "send('popup_open', JSON.stringify({")
103+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")
104+ require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
105+ require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
106+}
107+
100108 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101109 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102110 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
@@ -130,6 +138,22 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
130138 require.Equal(t, "cut", req.Action)
131139 }
132140
141+func TestDecodeRendererBridgePopupOpenPayload(t *testing.T) {
142+ req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true}`))
143+ require.NoError(t, err)
144+ require.Equal(t, "popup-1", req.ProxyID)
145+ require.Equal(t, "https://example.com/login", req.URL)
146+ require.Equal(t, "_blank", req.FrameName)
147+ require.True(t, req.UserGesture)
148+}
149+
150+func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
151+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
152+ require.NoError(t, err)
153+ require.Equal(t, "popup-1", req.ProxyID)
154+ require.Equal(t, "https://example.com/callback", req.URL)
155+}
156+
133157 func parseCEFPackageFiles(t *testing.T) []*ast.File {
134158 t.Helper()
135159 entries, err := os.ReadDir(".")
@@ -97,6 +97,14 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
98 }98 }
99 99
100+func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101+ require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify({")
103+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")
104+ require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
105+ require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
106+}
107+
100 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {108 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")109 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")110 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
@@ -130,6 +138,22 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
130 require.Equal(t, "cut", req.Action)138 require.Equal(t, "cut", req.Action)
131 }139 }
132 140
141+func TestDecodeRendererBridgePopupOpenPayload(t *testing.T) {
142+ req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true}`))
143+ require.NoError(t, err)
144+ require.Equal(t, "popup-1", req.ProxyID)
145+ require.Equal(t, "https://example.com/login", req.URL)
146+ require.Equal(t, "_blank", req.FrameName)
147+ require.True(t, req.UserGesture)
148+}
149+
150+func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
151+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
152+ require.NoError(t, err)
153+ require.Equal(t, "popup-1", req.ProxyID)
154+ require.Equal(t, "https://example.com/callback", req.URL)
155+}
156+
133 func parseCEFPackageFiles(t *testing.T) []*ast.File {157 func parseCEFPackageFiles(t *testing.T) []*ast.File {
134 t.Helper()158 t.Helper()
135 entries, err := os.ReadDir(".")159 entries, err := os.ReadDir(".")
M internal/infrastructure/cef/scheme_handler.go +66 -3
@@ -66,6 +66,12 @@ type dumbSchemeHandler struct {
6666 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
6767 onEditableFocus func(browser purecef.Browser)
6868
69+ // onPopupOpen/onPopupNavigate bridge synthetic window.open() proxies from page
70+ // JavaScript back into the browser process when native related popups are not
71+ // available (e.g. CEF OSR regular-webview fallback path).
72+ onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
73+ onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
74+
6975 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
7076 // browser/navigation context that issued the request.
7177 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
@@ -130,9 +136,10 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
130136 }
131137
132138 // Route API requests before redirecting conceptual dumb:// URLs to the
133- // internal HTTPS origin. External pages use dumb://api/... for clipboard and
134- // focus bridges specifically to bypass page CSP; redirecting those requests
135- // to https://dumber.invalid makes site connect-src policies block them.
139+ // internal HTTPS origin. External pages use dumb://api/... for clipboard,
140+ // focus, and popup bridges specifically to bypass page CSP; redirecting
141+ // those requests to https://dumber.invalid makes site connect-src policies
142+ // block them.
136143 if apiPath, ok := resolveAPIPath(u); ok {
137144 return h.handleAPI(browser, method, apiPath, request)
138145 }
@@ -184,6 +191,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
184191 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
185192 return h.handleFocusSync(request, browser)
186193
194+ case path == "/api/popup-open" && strings.EqualFold(method, "POST"):
195+ return h.handlePopupOpen(request, browser)
196+
197+ case path == "/api/popup-navigate" && strings.EqualFold(method, "POST"):
198+ return h.handlePopupNavigate(request, browser)
199+
187200 default:
188201 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
189202 }
@@ -331,6 +344,56 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
331344 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
332345 }
333346
347+func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
348+ if browser == nil {
349+ h.logger.Debug().Msg("cef: popup-open — browser unavailable")
350+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
351+ }
352+ if !h.hasTrustedBridgeNonce(request, browser) {
353+ h.logger.Warn().Msg("cef: popup-open — rejected request without valid bridge nonce")
354+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
355+ }
356+ body := readBodyFromHeader(request)
357+ if body == nil {
358+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
359+ }
360+ payload, err := decodeRendererBridgePopupOpenPayload(body)
361+ if err != nil {
362+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
363+ }
364+ if h.onPopupOpen != nil {
365+ h.onPopupOpen(browser, payload)
366+ } else {
367+ h.logger.Warn().Msg("cef: popup-open — callback not wired")
368+ }
369+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
370+}
371+
372+func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
373+ if browser == nil {
374+ h.logger.Debug().Msg("cef: popup-navigate — browser unavailable")
375+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
376+ }
377+ if !h.hasTrustedBridgeNonce(request, browser) {
378+ h.logger.Warn().Msg("cef: popup-navigate — rejected request without valid bridge nonce")
379+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
380+ }
381+ body := readBodyFromHeader(request)
382+ if body == nil {
383+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
384+ }
385+ payload, err := decodeRendererBridgePopupNavigatePayload(body)
386+ if err != nil {
387+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
388+ }
389+ if h.onPopupNavigate != nil {
390+ h.onPopupNavigate(browser, payload)
391+ } else {
392+ h.logger.Warn().Msg("cef: popup-navigate — callback not wired")
393+ }
394+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
395+}
396+
334397 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335398 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336399 return false
@@ -66,6 +66,12 @@ type dumbSchemeHandler struct {
66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
67 onEditableFocus func(browser purecef.Browser)67 onEditableFocus func(browser purecef.Browser)
68 68
69+ // onPopupOpen/onPopupNavigate bridge synthetic window.open() proxies from page
70+ // JavaScript back into the browser process when native related popups are not
71+ // available (e.g. CEF OSR regular-webview fallback path).
72+ onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
73+ onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
74+
69 // bridgeNonceValidator checks whether a bridge nonce belongs to the active75 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
70 // browser/navigation context that issued the request.76 // browser/navigation context that issued the request.
71 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool77 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
@@ -130,9 +136,10 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
130 }136 }
131 137
132 // Route API requests before redirecting conceptual dumb:// URLs to the138 // Route API requests before redirecting conceptual dumb:// URLs to the
133- // internal HTTPS origin. External pages use dumb://api/... for clipboard and139+ // internal HTTPS origin. External pages use dumb://api/... for clipboard,
134- // focus bridges specifically to bypass page CSP; redirecting those requests140+ // focus, and popup bridges specifically to bypass page CSP; redirecting
135- // to https://dumber.invalid makes site connect-src policies block them.141+ // those requests to https://dumber.invalid makes site connect-src policies
142+ // block them.
136 if apiPath, ok := resolveAPIPath(u); ok {143 if apiPath, ok := resolveAPIPath(u); ok {
137 return h.handleAPI(browser, method, apiPath, request)144 return h.handleAPI(browser, method, apiPath, request)
138 }145 }
@@ -184,6 +191,12 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
184 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):191 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
185 return h.handleFocusSync(request, browser)192 return h.handleFocusSync(request, browser)
186 193
194+ case path == "/api/popup-open" && strings.EqualFold(method, "POST"):
195+ return h.handlePopupOpen(request, browser)
196+
197+ case path == "/api/popup-navigate" && strings.EqualFold(method, "POST"):
198+ return h.handlePopupNavigate(request, browser)
199+
187 default:200 default:
188 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})201 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
189 }202 }
@@ -331,6 +344,56 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
331 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})344 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
332 }345 }
333 346
347+func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
348+ if browser == nil {
349+ h.logger.Debug().Msg("cef: popup-open — browser unavailable")
350+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
351+ }
352+ if !h.hasTrustedBridgeNonce(request, browser) {
353+ h.logger.Warn().Msg("cef: popup-open — rejected request without valid bridge nonce")
354+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
355+ }
356+ body := readBodyFromHeader(request)
357+ if body == nil {
358+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
359+ }
360+ payload, err := decodeRendererBridgePopupOpenPayload(body)
361+ if err != nil {
362+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
363+ }
364+ if h.onPopupOpen != nil {
365+ h.onPopupOpen(browser, payload)
366+ } else {
367+ h.logger.Warn().Msg("cef: popup-open — callback not wired")
368+ }
369+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
370+}
371+
372+func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
373+ if browser == nil {
374+ h.logger.Debug().Msg("cef: popup-navigate — browser unavailable")
375+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
376+ }
377+ if !h.hasTrustedBridgeNonce(request, browser) {
378+ h.logger.Warn().Msg("cef: popup-navigate — rejected request without valid bridge nonce")
379+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
380+ }
381+ body := readBodyFromHeader(request)
382+ if body == nil {
383+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
384+ }
385+ payload, err := decodeRendererBridgePopupNavigatePayload(body)
386+ if err != nil {
387+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
388+ }
389+ if h.onPopupNavigate != nil {
390+ h.onPopupNavigate(browser, payload)
391+ } else {
392+ h.logger.Warn().Msg("cef: popup-navigate — callback not wired")
393+ }
394+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
395+}
396+
334 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {397 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {398 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336 return false399 return false
M internal/infrastructure/cef/scheme_handler_test.go +84 -0
@@ -235,6 +235,90 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
235235 require.Positive(t, responseLength)
236236 }
237237
238+func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
239+ oldNewResourceHandler := cefNewResourceHandler
240+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
241+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
242+
243+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
244+ require.NoError(t, err)
245+
246+ const bridgeNonce = "bridge-nonce"
247+ browser := cefmocks.NewMockBrowser(t)
248+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
249+ return got == browser && gotNonce == bridgeNonce
250+ }
251+
252+ var got rendererBridgePopupOpenPayload
253+ h.onPopupOpen = func(gotBrowser purecef.Browser, payload rendererBridgePopupOpenPayload) {
254+ require.Same(t, browser, gotBrowser)
255+ got = payload
256+ }
257+
258+ request := cefmocks.NewMockRequest(t)
259+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
260+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true}`))
261+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
262+
263+ response := cefmocks.NewMockResponse(t)
264+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
265+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
266+ response.EXPECT().SetMimeType("application/json").Once()
267+ expectAPIResponseHeaders(response)
268+
269+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-open", request)
270+ require.NotNil(t, handler)
271+
272+ var responseLength int64
273+ handler.GetResponseHeaders(response, &responseLength, 0)
274+ require.Positive(t, responseLength)
275+ require.Equal(t, "popup-1", got.ProxyID)
276+ require.Equal(t, "https://example.com/login", got.URL)
277+ require.Equal(t, "Google login", got.FrameName)
278+ require.True(t, got.UserGesture)
279+}
280+
281+func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
282+ oldNewResourceHandler := cefNewResourceHandler
283+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
284+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
285+
286+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
287+ require.NoError(t, err)
288+
289+ const bridgeNonce = "bridge-nonce"
290+ browser := cefmocks.NewMockBrowser(t)
291+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
292+ return got == browser && gotNonce == bridgeNonce
293+ }
294+
295+ var got rendererBridgePopupNavigatePayload
296+ h.onPopupNavigate = func(gotBrowser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
297+ require.Same(t, browser, gotBrowser)
298+ got = payload
299+ }
300+
301+ request := cefmocks.NewMockRequest(t)
302+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
303+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
304+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
305+
306+ response := cefmocks.NewMockResponse(t)
307+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
308+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
309+ response.EXPECT().SetMimeType("application/json").Once()
310+ expectAPIResponseHeaders(response)
311+
312+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-navigate", request)
313+ require.NotNil(t, handler)
314+
315+ var responseLength int64
316+ handler.GetResponseHeaders(response, &responseLength, 0)
317+ require.Positive(t, responseLength)
318+ require.Equal(t, "popup-1", got.ProxyID)
319+ require.Equal(t, "https://example.com/callback", got.URL)
320+}
321+
238322 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
239323 oldNewResourceHandler := cefNewResourceHandler
240324 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -235,6 +235,90 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
235 require.Positive(t, responseLength)235 require.Positive(t, responseLength)
236 }236 }
237 237
238+func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
239+ oldNewResourceHandler := cefNewResourceHandler
240+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
241+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
242+
243+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
244+ require.NoError(t, err)
245+
246+ const bridgeNonce = "bridge-nonce"
247+ browser := cefmocks.NewMockBrowser(t)
248+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
249+ return got == browser && gotNonce == bridgeNonce
250+ }
251+
252+ var got rendererBridgePopupOpenPayload
253+ h.onPopupOpen = func(gotBrowser purecef.Browser, payload rendererBridgePopupOpenPayload) {
254+ require.Same(t, browser, gotBrowser)
255+ got = payload
256+ }
257+
258+ request := cefmocks.NewMockRequest(t)
259+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
260+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true}`))
261+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
262+
263+ response := cefmocks.NewMockResponse(t)
264+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
265+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
266+ response.EXPECT().SetMimeType("application/json").Once()
267+ expectAPIResponseHeaders(response)
268+
269+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-open", request)
270+ require.NotNil(t, handler)
271+
272+ var responseLength int64
273+ handler.GetResponseHeaders(response, &responseLength, 0)
274+ require.Positive(t, responseLength)
275+ require.Equal(t, "popup-1", got.ProxyID)
276+ require.Equal(t, "https://example.com/login", got.URL)
277+ require.Equal(t, "Google login", got.FrameName)
278+ require.True(t, got.UserGesture)
279+}
280+
281+func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
282+ oldNewResourceHandler := cefNewResourceHandler
283+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
284+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
285+
286+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
287+ require.NoError(t, err)
288+
289+ const bridgeNonce = "bridge-nonce"
290+ browser := cefmocks.NewMockBrowser(t)
291+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
292+ return got == browser && gotNonce == bridgeNonce
293+ }
294+
295+ var got rendererBridgePopupNavigatePayload
296+ h.onPopupNavigate = func(gotBrowser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
297+ require.Same(t, browser, gotBrowser)
298+ got = payload
299+ }
300+
301+ request := cefmocks.NewMockRequest(t)
302+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
303+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
304+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
305+
306+ response := cefmocks.NewMockResponse(t)
307+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
308+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
309+ response.EXPECT().SetMimeType("application/json").Once()
310+ expectAPIResponseHeaders(response)
311+
312+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-navigate", request)
313+ require.NotNil(t, handler)
314+
315+ var responseLength int64
316+ handler.GetResponseHeaders(response, &responseLength, 0)
317+ require.Positive(t, responseLength)
318+ require.Equal(t, "popup-1", got.ProxyID)
319+ require.Equal(t, "https://example.com/callback", got.URL)
320+}
321+
238 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {322 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
239 oldNewResourceHandler := cefNewResourceHandler323 oldNewResourceHandler := cefNewResourceHandler
240 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }324 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/cef/webview.go +7 -0
@@ -94,6 +94,10 @@ type WebView struct {
9494 mu sync.RWMutex
9595 callbacks *port.WebViewCallbacks
9696
97+ // Synthetic popup proxies created by the renderer bridge's window.open shim.
98+ syntheticPopupMu sync.Mutex
99+ syntheticPopups map[string]*syntheticPopupState
100+
97101 // State cache (mutex-protected).
98102 uri string
99103 title string
@@ -536,6 +540,9 @@ func (wv *WebView) Destroy() {
536540 if !wv.destroyed.CompareAndSwap(false, true) {
537541 return
538542 }
543+ wv.syntheticPopupMu.Lock()
544+ wv.syntheticPopups = nil
545+ wv.syntheticPopupMu.Unlock()
539546 wv.cancelSelectionDebounce()
540547 if wv.resizeReconciler != nil {
541548 wv.resizeReconciler.stop()
@@ -94,6 +94,10 @@ type WebView struct {
94 mu sync.RWMutex94 mu sync.RWMutex
95 callbacks *port.WebViewCallbacks95 callbacks *port.WebViewCallbacks
96 96
97+ // Synthetic popup proxies created by the renderer bridge's window.open shim.
98+ syntheticPopupMu sync.Mutex
99+ syntheticPopups map[string]*syntheticPopupState
100+
97 // State cache (mutex-protected).101 // State cache (mutex-protected).
98 uri string102 uri string
99 title string103 title string
@@ -536,6 +540,9 @@ func (wv *WebView) Destroy() {
536 if !wv.destroyed.CompareAndSwap(false, true) {540 if !wv.destroyed.CompareAndSwap(false, true) {
537 return541 return
538 }542 }
543+ wv.syntheticPopupMu.Lock()
544+ wv.syntheticPopups = nil
545+ wv.syntheticPopupMu.Unlock()
539 wv.cancelSelectionDebounce()546 wv.cancelSelectionDebounce()
540 if wv.resizeReconciler != nil {547 if wv.resizeReconciler != nil {
541 wv.resizeReconciler.stop()548 wv.resizeReconciler.stop()
M internal/infrastructure/cef/webview_handlers.go +14 -0
@@ -107,6 +107,20 @@ func (h *handlerSet) OnProcessMessageReceived(
107107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
108108 case rendererBridgeActionFocusSync:
109109 h.wv.engine.handleEditableFocusBridge(browser)
110+ case rendererBridgeActionPopupOpen:
111+ req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
112+ if err != nil {
113+ log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114+ return 1
115+ }
116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture)
117+ case rendererBridgeActionPopupNavigate:
118+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
119+ if err != nil {
120+ log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
121+ return 1
122+ }
123+ h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
110124 case rendererBridgeActionReady:
111125 log.Debug().
112126 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
@@ -107,6 +107,20 @@ func (h *handlerSet) OnProcessMessageReceived(
107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
108 case rendererBridgeActionFocusSync:108 case rendererBridgeActionFocusSync:
109 h.wv.engine.handleEditableFocusBridge(browser)109 h.wv.engine.handleEditableFocusBridge(browser)
110+ case rendererBridgeActionPopupOpen:
111+ req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
112+ if err != nil {
113+ log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114+ return 1
115+ }
116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture)
117+ case rendererBridgeActionPopupNavigate:
118+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
119+ if err != nil {
120+ log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
121+ return 1
122+ }
123+ h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
110 case rendererBridgeActionReady:124 case rendererBridgeActionReady:
111 log.Debug().125 log.Debug().
112 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).126 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
A internal/infrastructure/cef/webview_popup_proxy.go +125 -0
@@ -0,0 +1,125 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+type syntheticPopupState struct {
12+ WebView port.WebView
13+ PendingURI string
14+}
15+
16+func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
17+ if wv == nil || proxyID == "" {
18+ return nil
19+ }
20+
21+ wv.syntheticPopupMu.Lock()
22+ defer wv.syntheticPopupMu.Unlock()
23+
24+ if wv.syntheticPopups == nil {
25+ wv.syntheticPopups = make(map[string]*syntheticPopupState)
26+ }
27+ state := wv.syntheticPopups[proxyID]
28+ if state == nil {
29+ state = &syntheticPopupState{}
30+ wv.syntheticPopups[proxyID] = state
31+ }
32+ return state
33+}
34+
35+func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture bool) {
36+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
37+ return
38+ }
39+
40+ _ = wv.syntheticPopupState(proxyID)
41+ wv.runOnGTK(func() {
42+ if wv.destroyed.Load() {
43+ return
44+ }
45+
46+ wv.mu.RLock()
47+ cb := wv.callbacks
48+ wv.mu.RUnlock()
49+ if cb == nil || cb.OnCreate == nil {
50+ return
51+ }
52+
53+ popupWV := cb.OnCreate(port.PopupRequest{
54+ TargetURI: targetURL,
55+ FrameName: frameName,
56+ IsUserGesture: userGesture,
57+ ParentViewID: wv.id,
58+ })
59+ if popupWV == nil {
60+ return
61+ }
62+
63+ pendingURI := ""
64+ wv.syntheticPopupMu.Lock()
65+ if wv.syntheticPopups == nil {
66+ wv.syntheticPopups = make(map[string]*syntheticPopupState)
67+ }
68+ state := wv.syntheticPopups[proxyID]
69+ if state == nil {
70+ state = &syntheticPopupState{}
71+ wv.syntheticPopups[proxyID] = state
72+ }
73+ state.WebView = popupWV
74+ pendingURI = strings.TrimSpace(state.PendingURI)
75+ wv.syntheticPopupMu.Unlock()
76+
77+ if pendingURI == "" || pendingURI == strings.TrimSpace(targetURL) || popupWV.IsDestroyed() {
78+ return
79+ }
80+ if err := popupWV.LoadURI(context.Background(), pendingURI); err != nil {
81+ logging.FromContext(wv.ctx).Warn().
82+ Err(err).
83+ Str("proxy_id", proxyID).
84+ Str("uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
85+ Msg("cef: failed to replay synthetic popup navigation")
86+ }
87+ })
88+}
89+
90+func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
91+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
92+ return
93+ }
94+
95+ trimmedURI := strings.TrimSpace(targetURL)
96+ state := wv.syntheticPopupState(proxyID)
97+ if state == nil {
98+ return
99+ }
100+
101+ wv.syntheticPopupMu.Lock()
102+ state.PendingURI = trimmedURI
103+ popupWV := state.WebView
104+ wv.syntheticPopupMu.Unlock()
105+
106+ if trimmedURI == "" || popupWV == nil {
107+ return
108+ }
109+
110+ wv.runOnGTK(func() {
111+ if popupWV.IsDestroyed() {
112+ wv.syntheticPopupMu.Lock()
113+ delete(wv.syntheticPopups, proxyID)
114+ wv.syntheticPopupMu.Unlock()
115+ return
116+ }
117+ if err := popupWV.LoadURI(context.Background(), trimmedURI); err != nil {
118+ logging.FromContext(wv.ctx).Warn().
119+ Err(err).
120+ Str("proxy_id", proxyID).
121+ Str("uri", logging.TruncateURL(trimmedURI, logging.PermissionLogURLMaxLen)).
122+ Msg("cef: failed to navigate synthetic popup")
123+ }
124+ })
125+}
@@ -0,0 +1,125 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+type syntheticPopupState struct {
12+ WebView port.WebView
13+ PendingURI string
14+}
15+
16+func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
17+ if wv == nil || proxyID == "" {
18+ return nil
19+ }
20+
21+ wv.syntheticPopupMu.Lock()
22+ defer wv.syntheticPopupMu.Unlock()
23+
24+ if wv.syntheticPopups == nil {
25+ wv.syntheticPopups = make(map[string]*syntheticPopupState)
26+ }
27+ state := wv.syntheticPopups[proxyID]
28+ if state == nil {
29+ state = &syntheticPopupState{}
30+ wv.syntheticPopups[proxyID] = state
31+ }
32+ return state
33+}
34+
35+func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture bool) {
36+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
37+ return
38+ }
39+
40+ _ = wv.syntheticPopupState(proxyID)
41+ wv.runOnGTK(func() {
42+ if wv.destroyed.Load() {
43+ return
44+ }
45+
46+ wv.mu.RLock()
47+ cb := wv.callbacks
48+ wv.mu.RUnlock()
49+ if cb == nil || cb.OnCreate == nil {
50+ return
51+ }
52+
53+ popupWV := cb.OnCreate(port.PopupRequest{
54+ TargetURI: targetURL,
55+ FrameName: frameName,
56+ IsUserGesture: userGesture,
57+ ParentViewID: wv.id,
58+ })
59+ if popupWV == nil {
60+ return
61+ }
62+
63+ pendingURI := ""
64+ wv.syntheticPopupMu.Lock()
65+ if wv.syntheticPopups == nil {
66+ wv.syntheticPopups = make(map[string]*syntheticPopupState)
67+ }
68+ state := wv.syntheticPopups[proxyID]
69+ if state == nil {
70+ state = &syntheticPopupState{}
71+ wv.syntheticPopups[proxyID] = state
72+ }
73+ state.WebView = popupWV
74+ pendingURI = strings.TrimSpace(state.PendingURI)
75+ wv.syntheticPopupMu.Unlock()
76+
77+ if pendingURI == "" || pendingURI == strings.TrimSpace(targetURL) || popupWV.IsDestroyed() {
78+ return
79+ }
80+ if err := popupWV.LoadURI(context.Background(), pendingURI); err != nil {
81+ logging.FromContext(wv.ctx).Warn().
82+ Err(err).
83+ Str("proxy_id", proxyID).
84+ Str("uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
85+ Msg("cef: failed to replay synthetic popup navigation")
86+ }
87+ })
88+}
89+
90+func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
91+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
92+ return
93+ }
94+
95+ trimmedURI := strings.TrimSpace(targetURL)
96+ state := wv.syntheticPopupState(proxyID)
97+ if state == nil {
98+ return
99+ }
100+
101+ wv.syntheticPopupMu.Lock()
102+ state.PendingURI = trimmedURI
103+ popupWV := state.WebView
104+ wv.syntheticPopupMu.Unlock()
105+
106+ if trimmedURI == "" || popupWV == nil {
107+ return
108+ }
109+
110+ wv.runOnGTK(func() {
111+ if popupWV.IsDestroyed() {
112+ wv.syntheticPopupMu.Lock()
113+ delete(wv.syntheticPopups, proxyID)
114+ wv.syntheticPopupMu.Unlock()
115+ return
116+ }
117+ if err := popupWV.LoadURI(context.Background(), trimmedURI); err != nil {
118+ logging.FromContext(wv.ctx).Warn().
119+ Err(err).
120+ Str("proxy_id", proxyID).
121+ Str("uri", logging.TruncateURL(trimmedURI, logging.PermissionLogURLMaxLen)).
122+ Msg("cef: failed to navigate synthetic popup")
123+ }
124+ })
125+}
A internal/infrastructure/cef/webview_popup_proxy_test.go +56 -0
@@ -0,0 +1,56 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12+)
13+
14+func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
15+ popupWV := portmocks.NewMockWebView(t)
16+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
17+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/callback").Return(nil).Once()
18+
19+ parentWV := &WebView{
20+ ctx: context.Background(),
21+ id: port.WebViewID(17),
22+ callbacks: &port.WebViewCallbacks{
23+ OnCreate: func(req port.PopupRequest) port.WebView {
24+ require.Equal(t, "", req.TargetURI)
25+ require.Equal(t, "_blank", req.FrameName)
26+ require.True(t, req.IsUserGesture)
27+ require.Equal(t, port.WebViewID(17), req.ParentViewID)
28+ return popupWV
29+ },
30+ },
31+ }
32+
33+ parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")
34+ parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true)
35+}
36+
37+func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
38+ popupWV := portmocks.NewMockWebView(t)
39+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
40+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/finish").Return(nil).Once()
41+
42+ parentWV := &WebView{
43+ ctx: context.Background(),
44+ id: port.WebViewID(23),
45+ callbacks: &port.WebViewCallbacks{
46+ OnCreate: func(req port.PopupRequest) port.WebView {
47+ require.Equal(t, "https://example.com/start", req.TargetURI)
48+ require.Equal(t, "auth-popup", req.FrameName)
49+ return popupWV
50+ },
51+ },
52+ }
53+
54+ parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true)
55+ parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
56+}
@@ -0,0 +1,56 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12+)
13+
14+func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
15+ popupWV := portmocks.NewMockWebView(t)
16+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
17+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/callback").Return(nil).Once()
18+
19+ parentWV := &WebView{
20+ ctx: context.Background(),
21+ id: port.WebViewID(17),
22+ callbacks: &port.WebViewCallbacks{
23+ OnCreate: func(req port.PopupRequest) port.WebView {
24+ require.Equal(t, "", req.TargetURI)
25+ require.Equal(t, "_blank", req.FrameName)
26+ require.True(t, req.IsUserGesture)
27+ require.Equal(t, port.WebViewID(17), req.ParentViewID)
28+ return popupWV
29+ },
30+ },
31+ }
32+
33+ parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")
34+ parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true)
35+}
36+
37+func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
38+ popupWV := portmocks.NewMockWebView(t)
39+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
40+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/finish").Return(nil).Once()
41+
42+ parentWV := &WebView{
43+ ctx: context.Background(),
44+ id: port.WebViewID(23),
45+ callbacks: &port.WebViewCallbacks{
46+ OnCreate: func(req port.PopupRequest) port.WebView {
47+ require.Equal(t, "https://example.com/start", req.TargetURI)
48+ require.Equal(t, "auth-popup", req.FrameName)
49+ return popupWV
50+ },
51+ },
52+ }
53+
54+ parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true)
55+ parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
56+}
M internal/ui/coordinator/content/popup.go +96 -6
@@ -3,6 +3,8 @@ package content
33 import (
44 "context"
55 "fmt"
6+ neturl "net/url"
7+ "strings"
68 "time"
79
810 "github.com/bnema/dumber/internal/application/port"
@@ -267,11 +269,93 @@ func (c *Coordinator) createPopupPane(
267269 return paneID, popupPane
268270 }
269271
270-// handlePopupCreate handles the WebKit "create" signal for popup windows.
271-// Returns a new related WebView if popup is allowed, nil to block.
272+func normalizePopupTargetURIForFallback(rawTargetURI string) string {
273+ trimmed := strings.TrimSpace(rawTargetURI)
274+ if trimmed == "" {
275+ return rawTargetURI
276+ }
277+
278+ parsed, err := neturl.Parse(trimmed)
279+ if err != nil {
280+ return rawTargetURI
281+ }
282+
283+ host := strings.ToLower(parsed.Hostname())
284+ isNotionHost := host == "notion.so" || host == "notion.com" ||
285+ strings.HasSuffix(host, ".notion.so") || strings.HasSuffix(host, ".notion.com")
286+ if !isNotionHost || parsed.Path != "/verifyNoPopupBlockerHtmlAndRedirect" {
287+ return rawTargetURI
288+ }
289+
290+ redirectURI := strings.TrimSpace(parsed.Query().Get("redirectUri"))
291+ if redirectURI == "" {
292+ return rawTargetURI
293+ }
294+
295+ redirectParsed, err := neturl.Parse(redirectURI)
296+ if err != nil {
297+ return rawTargetURI
298+ }
299+ if redirectParsed.Scheme != "https" && redirectParsed.Scheme != "http" {
300+ return rawTargetURI
301+ }
302+
303+ return redirectParsed.String()
304+}
305+
306+// createPopupWebView prefers a related WebView so popups can share the
307+// parent session/context. If the engine does not support related popup views,
308+// it gracefully falls back to a regular WebView so target="_blank" and
309+// window.open() still open in a workspace pane.
310+func (c *Coordinator) createPopupWebView(
311+ ctx context.Context,
312+ parentID port.WebViewID,
313+ targetURI string,
314+) (port.WebView, string, error) {
315+ if c.factory == nil {
316+ return nil, targetURI, fmt.Errorf("no webview factory configured")
317+ }
318+
319+ log := logging.FromContext(ctx)
320+
321+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
322+ if err == nil && popupWV != nil {
323+ return popupWV, targetURI, nil
324+ }
325+ if err == nil {
326+ err = fmt.Errorf("related popup webview factory returned nil without error")
327+ }
328+
329+ log.Debug().
330+ Err(err).
331+ Uint64("parent_webview_id", uint64(parentID)).
332+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
333+ Msg("related popup webview unavailable, falling back to regular webview")
334+
335+ popupWV, fallbackErr := c.factory.Create(ctx)
336+ if fallbackErr != nil {
337+ return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", err, fallbackErr)
338+ }
339+ if popupWV == nil {
340+ return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")
341+ }
342+
343+ effectiveTargetURI := normalizePopupTargetURIForFallback(targetURI)
344+ if effectiveTargetURI != targetURI {
345+ log.Debug().
346+ Str("original_target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
347+ Str("effective_target_uri", logging.TruncateURL(effectiveTargetURI, logURLMaxLen)).
348+ Msg("rewrote popup target URI for regular-webview fallback")
349+ }
350+
351+ return popupWV, effectiveTargetURI, nil
352+}
353+
354+// handlePopupCreate handles a popup request from the current WebView.
355+// Returns a WebView if popup handling is allowed, nil to block.
272356 //
273357 // IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
274-// signal handler returns. Otherwise WebKit won't establish window.opener.
358+// signal handler returns for engines that require native popup lifecycles.
275359 // The WebView stays hidden until ready-to-show signal is received.
276360 func (c *Coordinator) handlePopupCreate(
277361 ctx context.Context,
@@ -305,13 +389,19 @@ func (c *Coordinator) handlePopupCreate(
305389 return reused
306390 }
307391
308- // Create related WebView for session sharing (created hidden)
309- popupWV, err := c.factory.CreateRelated(ctx, parentID)
392+ // Prefer a related WebView for session sharing, but fall back to a regular
393+ // WebView on engines like CEF OSR that do not implement native related
394+ // popup views yet.
395+ popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI)
310396 if err != nil {
311- log.Error().Err(err).Msg("failed to create related webview for popup")
397+ log.Error().Err(err).Msg("failed to create webview for popup")
312398 return nil
313399 }
314400
401+ if effectiveTargetURI != req.TargetURI {
402+ req.TargetURI = effectiveTargetURI
403+ }
404+
315405 // Detect popup type
316406 popupType := DetectPopupType(req.FrameName)
317407 popupID := popupWV.ID()
@@ -3,6 +3,8 @@ package content
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ neturl "net/url"
7+ "strings"
6 "time"8 "time"
7 9
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
@@ -267,11 +269,93 @@ func (c *Coordinator) createPopupPane(
267 return paneID, popupPane269 return paneID, popupPane
268 }270 }
269 271
270-// handlePopupCreate handles the WebKit "create" signal for popup windows.272+func normalizePopupTargetURIForFallback(rawTargetURI string) string {
271-// Returns a new related WebView if popup is allowed, nil to block.273+ trimmed := strings.TrimSpace(rawTargetURI)
274+ if trimmed == "" {
275+ return rawTargetURI
276+ }
277+
278+ parsed, err := neturl.Parse(trimmed)
279+ if err != nil {
280+ return rawTargetURI
281+ }
282+
283+ host := strings.ToLower(parsed.Hostname())
284+ isNotionHost := host == "notion.so" || host == "notion.com" ||
285+ strings.HasSuffix(host, ".notion.so") || strings.HasSuffix(host, ".notion.com")
286+ if !isNotionHost || parsed.Path != "/verifyNoPopupBlockerHtmlAndRedirect" {
287+ return rawTargetURI
288+ }
289+
290+ redirectURI := strings.TrimSpace(parsed.Query().Get("redirectUri"))
291+ if redirectURI == "" {
292+ return rawTargetURI
293+ }
294+
295+ redirectParsed, err := neturl.Parse(redirectURI)
296+ if err != nil {
297+ return rawTargetURI
298+ }
299+ if redirectParsed.Scheme != "https" && redirectParsed.Scheme != "http" {
300+ return rawTargetURI
301+ }
302+
303+ return redirectParsed.String()
304+}
305+
306+// createPopupWebView prefers a related WebView so popups can share the
307+// parent session/context. If the engine does not support related popup views,
308+// it gracefully falls back to a regular WebView so target="_blank" and
309+// window.open() still open in a workspace pane.
310+func (c *Coordinator) createPopupWebView(
311+ ctx context.Context,
312+ parentID port.WebViewID,
313+ targetURI string,
314+) (port.WebView, string, error) {
315+ if c.factory == nil {
316+ return nil, targetURI, fmt.Errorf("no webview factory configured")
317+ }
318+
319+ log := logging.FromContext(ctx)
320+
321+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
322+ if err == nil && popupWV != nil {
323+ return popupWV, targetURI, nil
324+ }
325+ if err == nil {
326+ err = fmt.Errorf("related popup webview factory returned nil without error")
327+ }
328+
329+ log.Debug().
330+ Err(err).
331+ Uint64("parent_webview_id", uint64(parentID)).
332+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
333+ Msg("related popup webview unavailable, falling back to regular webview")
334+
335+ popupWV, fallbackErr := c.factory.Create(ctx)
336+ if fallbackErr != nil {
337+ return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", err, fallbackErr)
338+ }
339+ if popupWV == nil {
340+ return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")
341+ }
342+
343+ effectiveTargetURI := normalizePopupTargetURIForFallback(targetURI)
344+ if effectiveTargetURI != targetURI {
345+ log.Debug().
346+ Str("original_target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
347+ Str("effective_target_uri", logging.TruncateURL(effectiveTargetURI, logURLMaxLen)).
348+ Msg("rewrote popup target URI for regular-webview fallback")
349+ }
350+
351+ return popupWV, effectiveTargetURI, nil
352+}
353+
354+// handlePopupCreate handles a popup request from the current WebView.
355+// Returns a WebView if popup handling is allowed, nil to block.
272 //356 //
273 // IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this357 // IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
274-// signal handler returns. Otherwise WebKit won't establish window.opener.358+// signal handler returns for engines that require native popup lifecycles.
275 // The WebView stays hidden until ready-to-show signal is received.359 // The WebView stays hidden until ready-to-show signal is received.
276 func (c *Coordinator) handlePopupCreate(360 func (c *Coordinator) handlePopupCreate(
277 ctx context.Context,361 ctx context.Context,
@@ -305,13 +389,19 @@ func (c *Coordinator) handlePopupCreate(
305 return reused389 return reused
306 }390 }
307 391
308- // Create related WebView for session sharing (created hidden)392+ // Prefer a related WebView for session sharing, but fall back to a regular
309- popupWV, err := c.factory.CreateRelated(ctx, parentID)393+ // WebView on engines like CEF OSR that do not implement native related
394+ // popup views yet.
395+ popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI)
310 if err != nil {396 if err != nil {
311- log.Error().Err(err).Msg("failed to create related webview for popup")397+ log.Error().Err(err).Msg("failed to create webview for popup")
312 return nil398 return nil
313 }399 }
314 400
401+ if effectiveTargetURI != req.TargetURI {
402+ req.TargetURI = effectiveTargetURI
403+ }
404+
315 // Detect popup type405 // Detect popup type
316 popupType := DetectPopupType(req.FrameName)406 popupType := DetectPopupType(req.FrameName)
317 popupID := popupWV.ID()407 popupID := popupWV.ID()
M internal/ui/coordinator/content/popup_test.go +102 -0
@@ -2,6 +2,7 @@ package content
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "testing"
78
@@ -125,6 +126,107 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
125126 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
126127 }
127128
129+func TestNormalizePopupTargetURIForFallback_RewritesNotionPopupVerifier(t *testing.T) {
130+ t.Parallel()
131+
132+ raw := "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc"
133+ assert.Equal(t,
134+ "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc",
135+ normalizePopupTargetURIForFallback(raw),
136+ )
137+}
138+
139+func TestNormalizePopupTargetURIForFallback_PreservesUnknownURLs(t *testing.T) {
140+ t.Parallel()
141+
142+ raw := "https://example.com/login-popup?redirectUri=https%3A%2F%2Fexample.com%2Fcallback"
143+ assert.Equal(t, raw, normalizePopupTargetURIForFallback(raw))
144+}
145+
146+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
147+ ctx := context.Background()
148+ parentPaneID := entity.PaneID("parent-pane")
149+ parentWV := mocks.NewMockWebView(t)
150+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
151+
152+ popupWV := mocks.NewMockWebView(t)
153+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
154+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
155+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
156+ popupWV.EXPECT().IsLoading().Return(false).Once()
157+ popupWV.EXPECT().URI().Return("").Once()
158+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
159+
160+ factory := mocks.NewMockWebViewFactory(t)
161+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
162+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
163+
164+ insertCalls := 0
165+ c := &Coordinator{
166+ webViews: make(map[entity.PaneID]port.WebView),
167+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
168+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
169+ }
170+ c.SetPopupConfig(factory, nil, nil)
171+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
172+ insertCalls++
173+ assert.Equal(t, parentPaneID, input.ParentPaneID)
174+ assert.Equal(t, popupWV, input.WebView)
175+ assert.Equal(t, "https://example.com/edit", input.TargetURI)
176+ return nil
177+ })
178+
179+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
180+ TargetURI: "https://example.com/edit",
181+ FrameName: "_blank",
182+ IsUserGesture: true,
183+ })
184+
185+ require.Same(t, popupWV, created)
186+ assert.Equal(t, 1, insertCalls)
187+}
188+
189+func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView(t *testing.T) {
190+ ctx := context.Background()
191+ parentPaneID := entity.PaneID("parent-pane")
192+ parentWV := mocks.NewMockWebView(t)
193+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
194+
195+ popupWV := mocks.NewMockWebView(t)
196+ popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
197+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
198+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
199+ popupWV.EXPECT().IsLoading().Return(false).Once()
200+ popupWV.EXPECT().URI().Return("").Once()
201+ popupWV.EXPECT().LoadURI(mock.Anything, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc").Return(nil).Once()
202+
203+ factory := mocks.NewMockWebViewFactory(t)
204+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
205+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
206+
207+ insertCalls := 0
208+ c := &Coordinator{
209+ webViews: make(map[entity.PaneID]port.WebView),
210+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
211+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
212+ }
213+ c.SetPopupConfig(factory, nil, nil)
214+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
215+ insertCalls++
216+ assert.Equal(t, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc", input.TargetURI)
217+ return nil
218+ })
219+
220+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
221+ TargetURI: "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc",
222+ FrameName: "Google login",
223+ IsUserGesture: true,
224+ })
225+
226+ require.Same(t, popupWV, created)
227+ assert.Equal(t, 1, insertCalls)
228+}
229+
128230 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
129231 ctx := context.Background()
130232 parentPaneID := entity.PaneID("parent-pane")
@@ -2,6 +2,7 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 8
@@ -125,6 +126,107 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
125 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))126 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
126 }127 }
127 128
129+func TestNormalizePopupTargetURIForFallback_RewritesNotionPopupVerifier(t *testing.T) {
130+ t.Parallel()
131+
132+ raw := "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc"
133+ assert.Equal(t,
134+ "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc",
135+ normalizePopupTargetURIForFallback(raw),
136+ )
137+}
138+
139+func TestNormalizePopupTargetURIForFallback_PreservesUnknownURLs(t *testing.T) {
140+ t.Parallel()
141+
142+ raw := "https://example.com/login-popup?redirectUri=https%3A%2F%2Fexample.com%2Fcallback"
143+ assert.Equal(t, raw, normalizePopupTargetURIForFallback(raw))
144+}
145+
146+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
147+ ctx := context.Background()
148+ parentPaneID := entity.PaneID("parent-pane")
149+ parentWV := mocks.NewMockWebView(t)
150+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
151+
152+ popupWV := mocks.NewMockWebView(t)
153+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
154+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
155+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
156+ popupWV.EXPECT().IsLoading().Return(false).Once()
157+ popupWV.EXPECT().URI().Return("").Once()
158+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
159+
160+ factory := mocks.NewMockWebViewFactory(t)
161+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
162+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
163+
164+ insertCalls := 0
165+ c := &Coordinator{
166+ webViews: make(map[entity.PaneID]port.WebView),
167+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
168+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
169+ }
170+ c.SetPopupConfig(factory, nil, nil)
171+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
172+ insertCalls++
173+ assert.Equal(t, parentPaneID, input.ParentPaneID)
174+ assert.Equal(t, popupWV, input.WebView)
175+ assert.Equal(t, "https://example.com/edit", input.TargetURI)
176+ return nil
177+ })
178+
179+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
180+ TargetURI: "https://example.com/edit",
181+ FrameName: "_blank",
182+ IsUserGesture: true,
183+ })
184+
185+ require.Same(t, popupWV, created)
186+ assert.Equal(t, 1, insertCalls)
187+}
188+
189+func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView(t *testing.T) {
190+ ctx := context.Background()
191+ parentPaneID := entity.PaneID("parent-pane")
192+ parentWV := mocks.NewMockWebView(t)
193+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
194+
195+ popupWV := mocks.NewMockWebView(t)
196+ popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
197+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
198+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
199+ popupWV.EXPECT().IsLoading().Return(false).Once()
200+ popupWV.EXPECT().URI().Return("").Once()
201+ popupWV.EXPECT().LoadURI(mock.Anything, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc").Return(nil).Once()
202+
203+ factory := mocks.NewMockWebViewFactory(t)
204+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
205+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
206+
207+ insertCalls := 0
208+ c := &Coordinator{
209+ webViews: make(map[entity.PaneID]port.WebView),
210+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
211+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
212+ }
213+ c.SetPopupConfig(factory, nil, nil)
214+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
215+ insertCalls++
216+ assert.Equal(t, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc", input.TargetURI)
217+ return nil
218+ })
219+
220+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
221+ TargetURI: "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc",
222+ FrameName: "Google login",
223+ IsUserGesture: true,
224+ })
225+
226+ require.Same(t, popupWV, created)
227+ assert.Equal(t, 1, insertCalls)
228+}
229+
128 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {230 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
129 ctx := context.Background()231 ctx := context.Background()
130 parentPaneID := entity.PaneID("parent-pane")232 parentPaneID := entity.PaneID("parent-pane")

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