detached 830fe74

fix(cef): honor popup opener security and close semantics

commit
830fe74cb8c74f62adb902f1aa14146ef8227ba5
author
brice · · 2026-04-21 12:44 UTC
parent
7e12a7d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +403 -51
M internal/application/port/webview.go +5 -4
@@ -64,10 +64,11 @@ const (
6464
6565 // PopupRequest contains metadata about a popup window request.
6666 type PopupRequest struct {
67- TargetURI string
68- FrameName string // e.g., "_blank", custom name, or empty
69- IsUserGesture bool
70- ParentViewID WebViewID
67+ TargetURI string
68+ FrameName string // e.g., "_blank", custom name, or empty
69+ IsUserGesture bool
70+ NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
71+ ParentViewID WebViewID
7172 }
7273
7374 // Texture represents a graphics texture returned by the engine.
@@ -64,10 +64,11 @@ const (
64 64
65 // PopupRequest contains metadata about a popup window request.65 // PopupRequest contains metadata about a popup window request.
66 type PopupRequest struct {66 type PopupRequest struct {
67- TargetURI string67+ TargetURI string
68- FrameName string // e.g., "_blank", custom name, or empty68+ FrameName string // e.g., "_blank", custom name, or empty
69- IsUserGesture bool69+ IsUserGesture bool
70- ParentViewID WebViewID70+ NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
71+ ParentViewID WebViewID
71 }72 }
72 73
73 // Texture represents a graphics texture returned by the engine.74 // Texture represents a graphics texture returned by the engine.
M internal/infrastructure/cef/content_injector.go +9 -2
@@ -673,6 +673,7 @@ const popupFetchBridgeJS = `(function() {
673673 function createSyntheticPopupProxy(proxyID, initialURL, features) {
674674 var href = initialURL || 'about:blank';
675675 var closed = false;
676+ var noJavaScriptAccess = popupHasNoOpener(features);
676677
677678 function navigate(nextURL) {
678679 href = resolvePopupURL(nextURL);
@@ -698,7 +699,11 @@ const popupFetchBridgeJS = `(function() {
698699
699700 var proxy = {
700701 blur: function() { return undefined; },
701- close: function() { closed = true; },
702+ close: function() {
703+ if (closed) return;
704+ closed = true;
705+ postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
706+ },
702707 focus: function() { return undefined; },
703708 postMessage: function() { return undefined; }
704709 };
@@ -741,12 +746,14 @@ const popupFetchBridgeJS = `(function() {
741746
742747 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
743748 var resolvedURL = resolvePopupURL(url);
749+ var noJavaScriptAccess = popupHasNoOpener(features);
744750 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
745751 postBridge('dumb:///api/popup-open', {
746752 proxy_id: proxyID,
747753 url: resolvedURL,
748754 frame_name: normalizedTarget,
749- user_gesture: hasUserGesture()
755+ user_gesture: hasUserGesture(),
756+ no_javascript_access: noJavaScriptAccess
750757 });
751758 return popupProxy;
752759 };
@@ -673,6 +673,7 @@ const popupFetchBridgeJS = `(function() {
673 function createSyntheticPopupProxy(proxyID, initialURL, features) {673 function createSyntheticPopupProxy(proxyID, initialURL, features) {
674 var href = initialURL || 'about:blank';674 var href = initialURL || 'about:blank';
675 var closed = false;675 var closed = false;
676+ var noJavaScriptAccess = popupHasNoOpener(features);
676 677
677 function navigate(nextURL) {678 function navigate(nextURL) {
678 href = resolvePopupURL(nextURL);679 href = resolvePopupURL(nextURL);
@@ -698,7 +699,11 @@ const popupFetchBridgeJS = `(function() {
698 699
699 var proxy = {700 var proxy = {
700 blur: function() { return undefined; },701 blur: function() { return undefined; },
701- close: function() { closed = true; },702+ close: function() {
703+ if (closed) return;
704+ closed = true;
705+ postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
706+ },
702 focus: function() { return undefined; },707 focus: function() { return undefined; },
703 postMessage: function() { return undefined; }708 postMessage: function() { return undefined; }
704 };709 };
@@ -741,12 +746,14 @@ const popupFetchBridgeJS = `(function() {
741 746
742 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);747 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
743 var resolvedURL = resolvePopupURL(url);748 var resolvedURL = resolvePopupURL(url);
749+ var noJavaScriptAccess = popupHasNoOpener(features);
744 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);750 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
745 postBridge('dumb:///api/popup-open', {751 postBridge('dumb:///api/popup-open', {
746 proxy_id: proxyID,752 proxy_id: proxyID,
747 url: resolvedURL,753 url: resolvedURL,
748 frame_name: normalizedTarget,754 frame_name: normalizedTarget,
749- user_gesture: hasUserGesture()755+ user_gesture: hasUserGesture(),
756+ no_javascript_access: noJavaScriptAccess
750 });757 });
751 return popupProxy;758 return popupProxy;
752 };759 };
M internal/infrastructure/cef/content_injector_test.go +2 -0
@@ -73,8 +73,10 @@ func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
7373 require.Contains(t, script, "window.open = function(url, target, features)")
7474 require.Contains(t, script, "dumb:///api/popup-open")
7575 require.Contains(t, script, "dumb:///api/popup-navigate")
76+ require.Contains(t, script, "dumb:///api/popup-close")
7677 require.Contains(t, script, "proxy_id")
7778 require.Contains(t, script, "createSyntheticPopupProxy")
79+ require.Contains(t, script, "no_javascript_access")
7880 require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
7981 require.Contains(t, script, "test-bridge-nonce")
8082 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
@@ -73,8 +73,10 @@ func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
73 require.Contains(t, script, "window.open = function(url, target, features)")73 require.Contains(t, script, "window.open = function(url, target, features)")
74 require.Contains(t, script, "dumb:///api/popup-open")74 require.Contains(t, script, "dumb:///api/popup-open")
75 require.Contains(t, script, "dumb:///api/popup-navigate")75 require.Contains(t, script, "dumb:///api/popup-navigate")
76+ require.Contains(t, script, "dumb:///api/popup-close")
76 require.Contains(t, script, "proxy_id")77 require.Contains(t, script, "proxy_id")
77 require.Contains(t, script, "createSyntheticPopupProxy")78 require.Contains(t, script, "createSyntheticPopupProxy")
79+ require.Contains(t, script, "no_javascript_access")
78 require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")80 require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
79 require.Contains(t, script, "test-bridge-nonce")81 require.Contains(t, script, "test-bridge-nonce")
80 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")82 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
M internal/infrastructure/cef/engine_init.go +1 -0
@@ -260,6 +260,7 @@ func wireEngine(
260260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
263+ schemeHandler.onPopupClose = eng.handlePopupBridgeClose
263264 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
264265
265266 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -260,6 +260,7 @@ func wireEngine(
260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
263+ schemeHandler.onPopupClose = eng.handlePopupBridgeClose
263 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest264 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
264 265
265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)266 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
M internal/infrastructure/cef/engine_popup_bridge.go +16 -1
@@ -42,7 +42,7 @@ func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload renderer
4242 Msg("cef: popup-open bridge could not resolve source webview")
4343 return
4444 }
45- wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture)
45+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
4646 }
4747
4848 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
@@ -59,3 +59,18 @@ func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rend
5959 }
6060 wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
6161 }
62+
63+func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {
64+ if e == nil {
65+ return
66+ }
67+ wv := e.webViewForBrowser(browser)
68+ if wv == nil {
69+ logging.FromContext(e.currentContext()).Warn().
70+ Int32("browser_id", browser.GetIdentifier()).
71+ Str("proxy_id", payload.ProxyID).
72+ Msg("cef: popup-close bridge could not resolve source webview")
73+ return
74+ }
75+ wv.handleSyntheticPopupClose(payload.ProxyID)
76+}
@@ -42,7 +42,7 @@ func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload renderer
42 Msg("cef: popup-open bridge could not resolve source webview")42 Msg("cef: popup-open bridge could not resolve source webview")
43 return43 return
44 }44 }
45- wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture)45+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
46 }46 }
47 47
48 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {48 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
@@ -59,3 +59,18 @@ func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rend
59 }59 }
60 wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)60 wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
61 }61 }
62+
63+func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {
64+ if e == nil {
65+ return
66+ }
67+ wv := e.webViewForBrowser(browser)
68+ if wv == nil {
69+ logging.FromContext(e.currentContext()).Warn().
70+ Int32("browser_id", browser.GetIdentifier()).
71+ Str("proxy_id", payload.ProxyID).
72+ Msg("cef: popup-close bridge could not resolve source webview")
73+ return
74+ }
75+ wv.handleSyntheticPopupClose(payload.ProxyID)
76+}
M internal/infrastructure/cef/renderer_bridge.go +33 -6
@@ -19,6 +19,7 @@ const (
1919 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
2020 rendererBridgeActionPopupOpen = "popup_open"
2121 rendererBridgeActionPopupNavigate = "popup_navigate"
22+ rendererBridgeActionPopupClose = "popup_close"
2223 rendererBridgeActionReady = "bridge_ready"
2324 rendererBridgeExtensionName = "dumber.renderer_bridge"
2425 )
@@ -97,6 +98,7 @@ const rendererBridgeExtensionJS = `
9798 function createSyntheticPopupProxy(proxyID, initialURL, features) {
9899 var href = initialURL || 'about:blank';
99100 var closed = false;
101+ var noJavaScriptAccess = popupHasNoOpener(features);
100102
101103 function navigate(nextURL) {
102104 href = resolvePopupURL(nextURL);
@@ -122,7 +124,11 @@ const rendererBridgeExtensionJS = `
122124
123125 var proxy = {
124126 blur: function() { return undefined; },
125- close: function() { closed = true; },
127+ close: function() {
128+ if (closed) return;
129+ closed = true;
130+ send('popup_close', JSON.stringify({ proxy_id: proxyID }));
131+ },
126132 focus: function() { return undefined; },
127133 postMessage: function() { return undefined; }
128134 };
@@ -173,12 +179,14 @@ const rendererBridgeExtensionJS = `
173179
174180 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
175181 var resolvedURL = resolvePopupURL(url);
182+ var noJavaScriptAccess = popupHasNoOpener(features);
176183 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
177184 send('popup_open', JSON.stringify({
178185 proxy_id: proxyID,
179186 url: resolvedURL,
180187 frame_name: normalizedTarget,
181- user_gesture: hasUserGesture()
188+ user_gesture: hasUserGesture(),
189+ no_javascript_access: noJavaScriptAccess
182190 }));
183191 return popupProxy;
184192 };
@@ -458,10 +466,11 @@ type rendererBridgeExplicitTextCopyPayload struct {
458466 }
459467
460468 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"`
469+ ProxyID string `json:"proxy_id"`
470+ URL string `json:"url"`
471+ FrameName string `json:"frame_name"`
472+ UserGesture bool `json:"user_gesture"`
473+ NoJavaScriptAccess bool `json:"no_javascript_access"`
465474 }
466475
467476 type rendererBridgePopupNavigatePayload struct {
@@ -469,6 +478,10 @@ type rendererBridgePopupNavigatePayload struct {
469478 URL string `json:"url"`
470479 }
471480
481+type rendererBridgePopupClosePayload struct {
482+ ProxyID string `json:"proxy_id"`
483+}
484+
472485 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
473486 var req rendererBridgeExplicitTextCopyPayload
474487 if len(payload) == 0 {
@@ -514,6 +527,20 @@ func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePop
514527 return req, nil
515528 }
516529
530+func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {
531+ var req rendererBridgePopupClosePayload
532+ if len(payload) == 0 {
533+ return req, fmt.Errorf("empty payload")
534+ }
535+ if err := json.Unmarshal(payload, &req); err != nil {
536+ return req, err
537+ }
538+ if req.ProxyID == "" {
539+ return req, fmt.Errorf("missing proxy_id")
540+ }
541+ return req, nil
542+}
543+
517544 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
518545 logger := logging.FromContext(e.currentContext())
519546 task := purecef.NewTask(cefTaskFunc(func() {
@@ -19,6 +19,7 @@ const (
19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20 rendererBridgeActionPopupOpen = "popup_open"20 rendererBridgeActionPopupOpen = "popup_open"
21 rendererBridgeActionPopupNavigate = "popup_navigate"21 rendererBridgeActionPopupNavigate = "popup_navigate"
22+ rendererBridgeActionPopupClose = "popup_close"
22 rendererBridgeActionReady = "bridge_ready"23 rendererBridgeActionReady = "bridge_ready"
23 rendererBridgeExtensionName = "dumber.renderer_bridge"24 rendererBridgeExtensionName = "dumber.renderer_bridge"
24 )25 )
@@ -97,6 +98,7 @@ const rendererBridgeExtensionJS = `
97 function createSyntheticPopupProxy(proxyID, initialURL, features) {98 function createSyntheticPopupProxy(proxyID, initialURL, features) {
98 var href = initialURL || 'about:blank';99 var href = initialURL || 'about:blank';
99 var closed = false;100 var closed = false;
101+ var noJavaScriptAccess = popupHasNoOpener(features);
100 102
101 function navigate(nextURL) {103 function navigate(nextURL) {
102 href = resolvePopupURL(nextURL);104 href = resolvePopupURL(nextURL);
@@ -122,7 +124,11 @@ const rendererBridgeExtensionJS = `
122 124
123 var proxy = {125 var proxy = {
124 blur: function() { return undefined; },126 blur: function() { return undefined; },
125- close: function() { closed = true; },127+ close: function() {
128+ if (closed) return;
129+ closed = true;
130+ send('popup_close', JSON.stringify({ proxy_id: proxyID }));
131+ },
126 focus: function() { return undefined; },132 focus: function() { return undefined; },
127 postMessage: function() { return undefined; }133 postMessage: function() { return undefined; }
128 };134 };
@@ -173,12 +179,14 @@ const rendererBridgeExtensionJS = `
173 179
174 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);180 var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
175 var resolvedURL = resolvePopupURL(url);181 var resolvedURL = resolvePopupURL(url);
182+ var noJavaScriptAccess = popupHasNoOpener(features);
176 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);183 var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
177 send('popup_open', JSON.stringify({184 send('popup_open', JSON.stringify({
178 proxy_id: proxyID,185 proxy_id: proxyID,
179 url: resolvedURL,186 url: resolvedURL,
180 frame_name: normalizedTarget,187 frame_name: normalizedTarget,
181- user_gesture: hasUserGesture()188+ user_gesture: hasUserGesture(),
189+ no_javascript_access: noJavaScriptAccess
182 }));190 }));
183 return popupProxy;191 return popupProxy;
184 };192 };
@@ -458,10 +466,11 @@ type rendererBridgeExplicitTextCopyPayload struct {
458 }466 }
459 467
460 type rendererBridgePopupOpenPayload struct {468 type rendererBridgePopupOpenPayload struct {
461- ProxyID string `json:"proxy_id"`469+ ProxyID string `json:"proxy_id"`
462- URL string `json:"url"`470+ URL string `json:"url"`
463- FrameName string `json:"frame_name"`471+ FrameName string `json:"frame_name"`
464- UserGesture bool `json:"user_gesture"`472+ UserGesture bool `json:"user_gesture"`
473+ NoJavaScriptAccess bool `json:"no_javascript_access"`
465 }474 }
466 475
467 type rendererBridgePopupNavigatePayload struct {476 type rendererBridgePopupNavigatePayload struct {
@@ -469,6 +478,10 @@ type rendererBridgePopupNavigatePayload struct {
469 URL string `json:"url"`478 URL string `json:"url"`
470 }479 }
471 480
481+type rendererBridgePopupClosePayload struct {
482+ ProxyID string `json:"proxy_id"`
483+}
484+
472 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {485 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
473 var req rendererBridgeExplicitTextCopyPayload486 var req rendererBridgeExplicitTextCopyPayload
474 if len(payload) == 0 {487 if len(payload) == 0 {
@@ -514,6 +527,20 @@ func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePop
514 return req, nil527 return req, nil
515 }528 }
516 529
530+func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {
531+ var req rendererBridgePopupClosePayload
532+ if len(payload) == 0 {
533+ return req, fmt.Errorf("empty payload")
534+ }
535+ if err := json.Unmarshal(payload, &req); err != nil {
536+ return req, err
537+ }
538+ if req.ProxyID == "" {
539+ return req, fmt.Errorf("missing proxy_id")
540+ }
541+ return req, nil
542+}
543+
517 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {544 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
518 logger := logging.FromContext(e.currentContext())545 logger := logging.FromContext(e.currentContext())
519 task := purecef.NewTask(cefTaskFunc(func() {546 task := purecef.NewTask(cefTaskFunc(func() {
M internal/infrastructure/cef/renderer_bridge_test.go +10 -1
@@ -101,6 +101,8 @@ func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.
101101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102102 require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify({")
103103 require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")
104+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_close', JSON.stringify({ proxy_id: proxyID }))")
105+ require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
104106 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
105107 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
106108 }
@@ -139,12 +141,13 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
139141 }
140142
141143 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}`))
144+ req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true,"no_javascript_access":true}`))
143145 require.NoError(t, err)
144146 require.Equal(t, "popup-1", req.ProxyID)
145147 require.Equal(t, "https://example.com/login", req.URL)
146148 require.Equal(t, "_blank", req.FrameName)
147149 require.True(t, req.UserGesture)
150+ require.True(t, req.NoJavaScriptAccess)
148151 }
149152
150153 func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
@@ -154,6 +157,12 @@ func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
154157 require.Equal(t, "https://example.com/callback", req.URL)
155158 }
156159
160+func TestDecodeRendererBridgePopupClosePayload(t *testing.T) {
161+ req, err := decodeRendererBridgePopupClosePayload([]byte(`{"proxy_id":"popup-1"}`))
162+ require.NoError(t, err)
163+ require.Equal(t, "popup-1", req.ProxyID)
164+}
165+
157166 func parseCEFPackageFiles(t *testing.T) []*ast.File {
158167 t.Helper()
159168 entries, err := os.ReadDir(".")
@@ -101,6 +101,8 @@ func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.
101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102 require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify({")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 }))")103 require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")
104+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_close', JSON.stringify({ proxy_id: proxyID }))")
105+ require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
104 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")106 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
105 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")107 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
106 }108 }
@@ -139,12 +141,13 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
139 }141 }
140 142
141 func TestDecodeRendererBridgePopupOpenPayload(t *testing.T) {143 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}`))144+ req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true,"no_javascript_access":true}`))
143 require.NoError(t, err)145 require.NoError(t, err)
144 require.Equal(t, "popup-1", req.ProxyID)146 require.Equal(t, "popup-1", req.ProxyID)
145 require.Equal(t, "https://example.com/login", req.URL)147 require.Equal(t, "https://example.com/login", req.URL)
146 require.Equal(t, "_blank", req.FrameName)148 require.Equal(t, "_blank", req.FrameName)
147 require.True(t, req.UserGesture)149 require.True(t, req.UserGesture)
150+ require.True(t, req.NoJavaScriptAccess)
148 }151 }
149 152
150 func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {153 func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
@@ -154,6 +157,12 @@ func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
154 require.Equal(t, "https://example.com/callback", req.URL)157 require.Equal(t, "https://example.com/callback", req.URL)
155 }158 }
156 159
160+func TestDecodeRendererBridgePopupClosePayload(t *testing.T) {
161+ req, err := decodeRendererBridgePopupClosePayload([]byte(`{"proxy_id":"popup-1"}`))
162+ require.NoError(t, err)
163+ require.Equal(t, "popup-1", req.ProxyID)
164+}
165+
157 func parseCEFPackageFiles(t *testing.T) []*ast.File {166 func parseCEFPackageFiles(t *testing.T) []*ast.File {
158 t.Helper()167 t.Helper()
159 entries, err := os.ReadDir(".")168 entries, err := os.ReadDir(".")
M internal/infrastructure/cef/scheme_handler.go +32 -3
@@ -66,11 +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).
69+ // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
70+ // proxies from page JavaScript back into the browser process when native
71+ // related popups are not available (e.g. CEF OSR regular-webview fallback path).
7272 onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
7373 onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
74+ onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
7475
7576 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
7677 // browser/navigation context that issued the request.
@@ -197,6 +198,9 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
197198 case path == "/api/popup-navigate" && strings.EqualFold(method, "POST"):
198199 return h.handlePopupNavigate(request, browser)
199200
201+ case path == "/api/popup-close" && strings.EqualFold(method, "POST"):
202+ return h.handlePopupClose(request, browser)
203+
200204 default:
201205 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
202206 }
@@ -394,6 +398,31 @@ func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser
394398 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
395399 }
396400
401+func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
402+ if browser == nil {
403+ h.logger.Debug().Msg("cef: popup-close — browser unavailable")
404+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
405+ }
406+ if !h.hasTrustedBridgeNonce(request, browser) {
407+ h.logger.Warn().Msg("cef: popup-close — rejected request without valid bridge nonce")
408+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
409+ }
410+ body := readBodyFromHeader(request)
411+ if body == nil {
412+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
413+ }
414+ payload, err := decodeRendererBridgePopupClosePayload(body)
415+ if err != nil {
416+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
417+ }
418+ if h.onPopupClose != nil {
419+ h.onPopupClose(browser, payload)
420+ } else {
421+ h.logger.Warn().Msg("cef: popup-close — callback not wired")
422+ }
423+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
424+}
425+
397426 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
398427 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
399428 return false
@@ -66,11 +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 page69+ // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
70- // JavaScript back into the browser process when native related popups are not70+ // proxies from page JavaScript back into the browser process when native
71- // available (e.g. CEF OSR regular-webview fallback path).71+ // related popups are not available (e.g. CEF OSR regular-webview fallback path).
72 onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)72 onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
73 onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)73 onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
74+ onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
74 75
75 // bridgeNonceValidator checks whether a bridge nonce belongs to the active76 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
76 // browser/navigation context that issued the request.77 // browser/navigation context that issued the request.
@@ -197,6 +198,9 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
197 case path == "/api/popup-navigate" && strings.EqualFold(method, "POST"):198 case path == "/api/popup-navigate" && strings.EqualFold(method, "POST"):
198 return h.handlePopupNavigate(request, browser)199 return h.handlePopupNavigate(request, browser)
199 200
201+ case path == "/api/popup-close" && strings.EqualFold(method, "POST"):
202+ return h.handlePopupClose(request, browser)
203+
200 default:204 default:
201 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})205 return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
202 }206 }
@@ -394,6 +398,31 @@ func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser
394 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})398 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
395 }399 }
396 400
401+func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
402+ if browser == nil {
403+ h.logger.Debug().Msg("cef: popup-close — browser unavailable")
404+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
405+ }
406+ if !h.hasTrustedBridgeNonce(request, browser) {
407+ h.logger.Warn().Msg("cef: popup-close — rejected request without valid bridge nonce")
408+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
409+ }
410+ body := readBodyFromHeader(request)
411+ if body == nil {
412+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
413+ }
414+ payload, err := decodeRendererBridgePopupClosePayload(body)
415+ if err != nil {
416+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
417+ }
418+ if h.onPopupClose != nil {
419+ h.onPopupClose(browser, payload)
420+ } else {
421+ h.logger.Warn().Msg("cef: popup-close — callback not wired")
422+ }
423+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
424+}
425+
397 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {426 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
398 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {427 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
399 return false428 return false
M internal/infrastructure/cef/scheme_handler_test.go +42 -1
@@ -257,7 +257,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
257257
258258 request := cefmocks.NewMockRequest(t)
259259 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}`))
260+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true,"no_javascript_access":true}`))
261261 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
262262
263263 response := cefmocks.NewMockResponse(t)
@@ -276,6 +276,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
276276 require.Equal(t, "https://example.com/login", got.URL)
277277 require.Equal(t, "Google login", got.FrameName)
278278 require.True(t, got.UserGesture)
279+ require.True(t, got.NoJavaScriptAccess)
279280 }
280281
281282 func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
@@ -319,6 +320,46 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
319320 require.Equal(t, "https://example.com/callback", got.URL)
320321 }
321322
323+func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) {
324+ oldNewResourceHandler := cefNewResourceHandler
325+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
326+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
327+
328+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
329+ require.NoError(t, err)
330+
331+ const bridgeNonce = "bridge-nonce"
332+ browser := cefmocks.NewMockBrowser(t)
333+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
334+ return got == browser && gotNonce == bridgeNonce
335+ }
336+
337+ var got rendererBridgePopupClosePayload
338+ h.onPopupClose = func(gotBrowser purecef.Browser, payload rendererBridgePopupClosePayload) {
339+ require.Same(t, browser, gotBrowser)
340+ got = payload
341+ }
342+
343+ request := cefmocks.NewMockRequest(t)
344+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
345+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1"}`))
346+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
347+
348+ response := cefmocks.NewMockResponse(t)
349+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
350+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
351+ response.EXPECT().SetMimeType("application/json").Once()
352+ expectAPIResponseHeaders(response)
353+
354+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-close", request)
355+ require.NotNil(t, handler)
356+
357+ var responseLength int64
358+ handler.GetResponseHeaders(response, &responseLength, 0)
359+ require.Positive(t, responseLength)
360+ require.Equal(t, "popup-1", got.ProxyID)
361+}
362+
322363 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
323364 oldNewResourceHandler := cefNewResourceHandler
324365 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -257,7 +257,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
257 257
258 request := cefmocks.NewMockRequest(t)258 request := cefmocks.NewMockRequest(t)
259 request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()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}`))260+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true,"no_javascript_access":true}`))
261 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()261 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
262 262
263 response := cefmocks.NewMockResponse(t)263 response := cefmocks.NewMockResponse(t)
@@ -276,6 +276,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
276 require.Equal(t, "https://example.com/login", got.URL)276 require.Equal(t, "https://example.com/login", got.URL)
277 require.Equal(t, "Google login", got.FrameName)277 require.Equal(t, "Google login", got.FrameName)
278 require.True(t, got.UserGesture)278 require.True(t, got.UserGesture)
279+ require.True(t, got.NoJavaScriptAccess)
279 }280 }
280 281
281 func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {282 func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
@@ -319,6 +320,46 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
319 require.Equal(t, "https://example.com/callback", got.URL)320 require.Equal(t, "https://example.com/callback", got.URL)
320 }321 }
321 322
323+func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) {
324+ oldNewResourceHandler := cefNewResourceHandler
325+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
326+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
327+
328+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
329+ require.NoError(t, err)
330+
331+ const bridgeNonce = "bridge-nonce"
332+ browser := cefmocks.NewMockBrowser(t)
333+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
334+ return got == browser && gotNonce == bridgeNonce
335+ }
336+
337+ var got rendererBridgePopupClosePayload
338+ h.onPopupClose = func(gotBrowser purecef.Browser, payload rendererBridgePopupClosePayload) {
339+ require.Same(t, browser, gotBrowser)
340+ got = payload
341+ }
342+
343+ request := cefmocks.NewMockRequest(t)
344+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
345+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1"}`))
346+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
347+
348+ response := cefmocks.NewMockResponse(t)
349+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
350+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
351+ response.EXPECT().SetMimeType("application/json").Once()
352+ expectAPIResponseHeaders(response)
353+
354+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-close", request)
355+ require.NotNil(t, handler)
356+
357+ var responseLength int64
358+ handler.GetResponseHeaders(response, &responseLength, 0)
359+ require.Positive(t, responseLength)
360+ require.Equal(t, "popup-1", got.ProxyID)
361+}
362+
322 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {363 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
323 oldNewResourceHandler := cefNewResourceHandler364 oldNewResourceHandler := cefNewResourceHandler
324 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }365 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/cef/webview.go +79 -0
@@ -26,6 +26,7 @@ var (
2626 _ port.WebView = (*WebView)(nil)
2727 _ port.NativeWidgetProvider = (*WebView)(nil)
2828 _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.OAuthCallbackCapable = (*WebView)(nil)
2930 )
3031
3132 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -98,6 +99,11 @@ type WebView struct {
9899 syntheticPopupMu sync.Mutex
99100 syntheticPopups map[string]*syntheticPopupState
100101
102+ // Programmatic popup lifecycle callbacks used for OAuth auto-close and
103+ // synthetic window.open() proxy support on CEF.
104+ closeCallbacks []func()
105+ navigationCallbacks []func(string)
106+
101107 // State cache (mutex-protected).
102108 uri string
103109 title string
@@ -469,6 +475,41 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
469475 wv.callbacks = cb
470476 }
471477
478+// AddCloseCallback implements port.OAuthCallbackCapable.
479+func (wv *WebView) AddCloseCallback(fn func()) {
480+ if wv == nil || fn == nil {
481+ return
482+ }
483+ wv.mu.Lock()
484+ wv.closeCallbacks = append(wv.closeCallbacks, fn)
485+ wv.mu.Unlock()
486+}
487+
488+// AddNavigationCallback implements port.OAuthCallbackCapable.
489+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
490+ if wv == nil || fn == nil {
491+ return
492+ }
493+ wv.mu.Lock()
494+ wv.navigationCallbacks = append(wv.navigationCallbacks, fn)
495+ wv.mu.Unlock()
496+}
497+
498+// Close implements port.OAuthCallbackCapable.
499+func (wv *WebView) Close() {
500+ if wv == nil || wv.destroyed.Load() {
501+ return
502+ }
503+ wv.mu.RLock()
504+ host := wv.host
505+ wv.mu.RUnlock()
506+ if host != nil {
507+ host.CloseBrowser(1)
508+ return
509+ }
510+ wv.runCloseCallbacks()
511+}
512+
472513 // ---------------------------------------------------------------------------
473514 // JavaScript / Appearance
474515 // ---------------------------------------------------------------------------
@@ -543,6 +584,10 @@ func (wv *WebView) Destroy() {
543584 wv.syntheticPopupMu.Lock()
544585 wv.syntheticPopups = nil
545586 wv.syntheticPopupMu.Unlock()
587+ wv.mu.Lock()
588+ wv.closeCallbacks = nil
589+ wv.navigationCallbacks = nil
590+ wv.mu.Unlock()
546591 wv.cancelSelectionDebounce()
547592 if wv.resizeReconciler != nil {
548593 wv.resizeReconciler.stop()
@@ -600,6 +645,7 @@ func (wv *WebView) updateURI(uri string) {
600645 cb.OnURIChanged(uri)
601646 })
602647 }
648+ wv.runNavigationCallbacks(uri)
603649 }
604650
605651 func (wv *WebView) updateTitle(title string) {
@@ -1126,6 +1172,39 @@ func (wv *WebView) runOnGTK(fn func()) {
11261172 glib.IdleAddOnce(cb, 0)
11271173 }
11281174
1175+func (wv *WebView) runCloseCallbacks() {
1176+ if wv == nil {
1177+ return
1178+ }
1179+ wv.mu.RLock()
1180+ callbacks := append([]func(){}, wv.closeCallbacks...)
1181+ wv.mu.RUnlock()
1182+ for _, fn := range callbacks {
1183+ if fn != nil {
1184+ fn()
1185+ }
1186+ }
1187+}
1188+
1189+func (wv *WebView) runNavigationCallbacks(uri string) {
1190+ if wv == nil {
1191+ return
1192+ }
1193+ wv.mu.RLock()
1194+ callbacks := append([]func(string){}, wv.navigationCallbacks...)
1195+ wv.mu.RUnlock()
1196+ if len(callbacks) == 0 {
1197+ return
1198+ }
1199+ wv.runOnGTK(func() {
1200+ for _, fn := range callbacks {
1201+ if fn != nil {
1202+ fn(uri)
1203+ }
1204+ }
1205+ })
1206+}
1207+
11291208 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
11301209 wv.mu.Lock()
11311210 defer wv.mu.Unlock()
@@ -26,6 +26,7 @@ var (
26 _ port.WebView = (*WebView)(nil)26 _ port.WebView = (*WebView)(nil)
27 _ port.NativeWidgetProvider = (*WebView)(nil)27 _ port.NativeWidgetProvider = (*WebView)(nil)
28 _ port.DevToolsOpener = (*WebView)(nil)28 _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.OAuthCallbackCapable = (*WebView)(nil)
29 )30 )
30 31
31 // errDestroyed is returned when an operation is attempted on a destroyed WebView.32 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -98,6 +99,11 @@ type WebView struct {
98 syntheticPopupMu sync.Mutex99 syntheticPopupMu sync.Mutex
99 syntheticPopups map[string]*syntheticPopupState100 syntheticPopups map[string]*syntheticPopupState
100 101
102+ // Programmatic popup lifecycle callbacks used for OAuth auto-close and
103+ // synthetic window.open() proxy support on CEF.
104+ closeCallbacks []func()
105+ navigationCallbacks []func(string)
106+
101 // State cache (mutex-protected).107 // State cache (mutex-protected).
102 uri string108 uri string
103 title string109 title string
@@ -469,6 +475,41 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
469 wv.callbacks = cb475 wv.callbacks = cb
470 }476 }
471 477
478+// AddCloseCallback implements port.OAuthCallbackCapable.
479+func (wv *WebView) AddCloseCallback(fn func()) {
480+ if wv == nil || fn == nil {
481+ return
482+ }
483+ wv.mu.Lock()
484+ wv.closeCallbacks = append(wv.closeCallbacks, fn)
485+ wv.mu.Unlock()
486+}
487+
488+// AddNavigationCallback implements port.OAuthCallbackCapable.
489+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
490+ if wv == nil || fn == nil {
491+ return
492+ }
493+ wv.mu.Lock()
494+ wv.navigationCallbacks = append(wv.navigationCallbacks, fn)
495+ wv.mu.Unlock()
496+}
497+
498+// Close implements port.OAuthCallbackCapable.
499+func (wv *WebView) Close() {
500+ if wv == nil || wv.destroyed.Load() {
501+ return
502+ }
503+ wv.mu.RLock()
504+ host := wv.host
505+ wv.mu.RUnlock()
506+ if host != nil {
507+ host.CloseBrowser(1)
508+ return
509+ }
510+ wv.runCloseCallbacks()
511+}
512+
472 // ---------------------------------------------------------------------------513 // ---------------------------------------------------------------------------
473 // JavaScript / Appearance514 // JavaScript / Appearance
474 // ---------------------------------------------------------------------------515 // ---------------------------------------------------------------------------
@@ -543,6 +584,10 @@ func (wv *WebView) Destroy() {
543 wv.syntheticPopupMu.Lock()584 wv.syntheticPopupMu.Lock()
544 wv.syntheticPopups = nil585 wv.syntheticPopups = nil
545 wv.syntheticPopupMu.Unlock()586 wv.syntheticPopupMu.Unlock()
587+ wv.mu.Lock()
588+ wv.closeCallbacks = nil
589+ wv.navigationCallbacks = nil
590+ wv.mu.Unlock()
546 wv.cancelSelectionDebounce()591 wv.cancelSelectionDebounce()
547 if wv.resizeReconciler != nil {592 if wv.resizeReconciler != nil {
548 wv.resizeReconciler.stop()593 wv.resizeReconciler.stop()
@@ -600,6 +645,7 @@ func (wv *WebView) updateURI(uri string) {
600 cb.OnURIChanged(uri)645 cb.OnURIChanged(uri)
601 })646 })
602 }647 }
648+ wv.runNavigationCallbacks(uri)
603 }649 }
604 650
605 func (wv *WebView) updateTitle(title string) {651 func (wv *WebView) updateTitle(title string) {
@@ -1126,6 +1172,39 @@ func (wv *WebView) runOnGTK(fn func()) {
1126 glib.IdleAddOnce(cb, 0)1172 glib.IdleAddOnce(cb, 0)
1127 }1173 }
1128 1174
1175+func (wv *WebView) runCloseCallbacks() {
1176+ if wv == nil {
1177+ return
1178+ }
1179+ wv.mu.RLock()
1180+ callbacks := append([]func(){}, wv.closeCallbacks...)
1181+ wv.mu.RUnlock()
1182+ for _, fn := range callbacks {
1183+ if fn != nil {
1184+ fn()
1185+ }
1186+ }
1187+}
1188+
1189+func (wv *WebView) runNavigationCallbacks(uri string) {
1190+ if wv == nil {
1191+ return
1192+ }
1193+ wv.mu.RLock()
1194+ callbacks := append([]func(string){}, wv.navigationCallbacks...)
1195+ wv.mu.RUnlock()
1196+ if len(callbacks) == 0 {
1197+ return
1198+ }
1199+ wv.runOnGTK(func() {
1200+ for _, fn := range callbacks {
1201+ if fn != nil {
1202+ fn(uri)
1203+ }
1204+ }
1205+ })
1206+}
1207+
1129 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {1208 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
1130 wv.mu.Lock()1209 wv.mu.Lock()
1131 defer wv.mu.Unlock()1210 defer wv.mu.Unlock()
M internal/infrastructure/cef/webview_handlers.go +19 -6
@@ -113,7 +113,7 @@ func (h *handlerSet) OnProcessMessageReceived(
113113 log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114114 return 1
115115 }
116- h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture)
116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
117117 case rendererBridgeActionPopupNavigate:
118118 req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
119119 if err != nil {
@@ -121,6 +121,13 @@ func (h *handlerSet) OnProcessMessageReceived(
121121 return 1
122122 }
123123 h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
124+ case rendererBridgeActionPopupClose:
125+ req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
126+ if err != nil {
127+ log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
128+ return 1
129+ }
130+ h.wv.handleSyntheticPopupClose(req.ProxyID)
124131 case rendererBridgeActionReady:
125132 log.Debug().
126133 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
@@ -671,7 +678,7 @@ func (h *handlerSet) OnBeforePopup(
671678 _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
672679 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
673680 _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,
674- _ *purecef.DictionaryValue, _ *bool,
681+ _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
675682 ) bool {
676683 if targetURL == "" {
677684 return true
@@ -682,11 +689,16 @@ func (h *handlerSet) OnBeforePopup(
682689 h.wv.mu.RUnlock()
683690
684691 if cb != nil && cb.OnCreate != nil {
692+ requestNoJavaScriptAccess := false
693+ if noJavaScriptAccess != nil {
694+ requestNoJavaScriptAccess = *noJavaScriptAccess
695+ }
685696 req := port.PopupRequest{
686- TargetURI: targetURL,
687- FrameName: targetFrameName,
688- IsUserGesture: userGesture != 0,
689- ParentViewID: h.wv.id,
697+ TargetURI: targetURL,
698+ FrameName: targetFrameName,
699+ IsUserGesture: userGesture != 0,
700+ NoJavaScriptAccess: requestNoJavaScriptAccess,
701+ ParentViewID: h.wv.id,
690702 }
691703 h.wv.runOnGTK(func() {
692704 cb.OnCreate(req)
@@ -794,6 +806,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
794806 cb.OnClose()
795807 })
796808 }
809+ h.wv.runCloseCallbacks()
797810 }
798811
799812 // ===========================================================================
@@ -113,7 +113,7 @@ func (h *handlerSet) OnProcessMessageReceived(
113 log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")113 log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114 return 1114 return 1
115 }115 }
116- h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture)116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
117 case rendererBridgeActionPopupNavigate:117 case rendererBridgeActionPopupNavigate:
118 req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))118 req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
119 if err != nil {119 if err != nil {
@@ -121,6 +121,13 @@ func (h *handlerSet) OnProcessMessageReceived(
121 return 1121 return 1
122 }122 }
123 h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)123 h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
124+ case rendererBridgeActionPopupClose:
125+ req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
126+ if err != nil {
127+ log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
128+ return 1
129+ }
130+ h.wv.handleSyntheticPopupClose(req.ProxyID)
124 case rendererBridgeActionReady:131 case rendererBridgeActionReady:
125 log.Debug().132 log.Debug().
126 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).133 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
@@ -671,7 +678,7 @@ func (h *handlerSet) OnBeforePopup(
671 _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,678 _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
672 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,679 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
673 _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,680 _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,
674- _ *purecef.DictionaryValue, _ *bool,681+ _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
675 ) bool {682 ) bool {
676 if targetURL == "" {683 if targetURL == "" {
677 return true684 return true
@@ -682,11 +689,16 @@ func (h *handlerSet) OnBeforePopup(
682 h.wv.mu.RUnlock()689 h.wv.mu.RUnlock()
683 690
684 if cb != nil && cb.OnCreate != nil {691 if cb != nil && cb.OnCreate != nil {
692+ requestNoJavaScriptAccess := false
693+ if noJavaScriptAccess != nil {
694+ requestNoJavaScriptAccess = *noJavaScriptAccess
695+ }
685 req := port.PopupRequest{696 req := port.PopupRequest{
686- TargetURI: targetURL,697+ TargetURI: targetURL,
687- FrameName: targetFrameName,698+ FrameName: targetFrameName,
688- IsUserGesture: userGesture != 0,699+ IsUserGesture: userGesture != 0,
689- ParentViewID: h.wv.id,700+ NoJavaScriptAccess: requestNoJavaScriptAccess,
701+ ParentViewID: h.wv.id,
690 }702 }
691 h.wv.runOnGTK(func() {703 h.wv.runOnGTK(func() {
692 cb.OnCreate(req)704 cb.OnCreate(req)
@@ -794,6 +806,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
794 cb.OnClose()806 cb.OnClose()
795 })807 })
796 }808 }
809+ h.wv.runCloseCallbacks()
797 }810 }
798 811
799 // ===========================================================================812 // ===========================================================================
M internal/infrastructure/cef/webview_popup_proxy.go +42 -8
@@ -9,8 +9,9 @@ import (
99 )
1010
1111 type syntheticPopupState struct {
12- WebView port.WebView
13- PendingURI string
12+ WebView port.WebView
13+ PendingURI string
14+ NoJavaScriptAccess bool
1415 }
1516
1617 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
@@ -32,12 +33,18 @@ func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
3233 return state
3334 }
3435
35-func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture bool) {
36+func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {
3637 if wv == nil || proxyID == "" || wv.destroyed.Load() {
3738 return
3839 }
3940
40- _ = wv.syntheticPopupState(proxyID)
41+ state := wv.syntheticPopupState(proxyID)
42+ if state != nil {
43+ wv.syntheticPopupMu.Lock()
44+ state.NoJavaScriptAccess = noJavaScriptAccess
45+ wv.syntheticPopupMu.Unlock()
46+ }
47+
4148 wv.runOnGTK(func() {
4249 if wv.destroyed.Load() {
4350 return
@@ -51,10 +58,11 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
5158 }
5259
5360 popupWV := cb.OnCreate(port.PopupRequest{
54- TargetURI: targetURL,
55- FrameName: frameName,
56- IsUserGesture: userGesture,
57- ParentViewID: wv.id,
61+ TargetURI: targetURL,
62+ FrameName: frameName,
63+ IsUserGesture: userGesture,
64+ NoJavaScriptAccess: noJavaScriptAccess,
65+ ParentViewID: wv.id,
5866 })
5967 if popupWV == nil {
6068 return
@@ -71,6 +79,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
7179 wv.syntheticPopups[proxyID] = state
7280 }
7381 state.WebView = popupWV
82+ state.NoJavaScriptAccess = noJavaScriptAccess
7483 pendingURI = strings.TrimSpace(state.PendingURI)
7584 wv.syntheticPopupMu.Unlock()
7685
@@ -123,3 +132,28 @@ func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
123132 }
124133 })
125134 }
135+
136+func (wv *WebView) handleSyntheticPopupClose(proxyID string) {
137+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
138+ return
139+ }
140+
141+ wv.syntheticPopupMu.Lock()
142+ state := wv.syntheticPopups[proxyID]
143+ if state != nil {
144+ delete(wv.syntheticPopups, proxyID)
145+ }
146+ wv.syntheticPopupMu.Unlock()
147+ if state == nil || state.WebView == nil {
148+ return
149+ }
150+
151+ closeable, ok := state.WebView.(port.OAuthCallbackCapable)
152+ if !ok {
153+ return
154+ }
155+
156+ wv.runOnGTK(func() {
157+ closeable.Close()
158+ })
159+}
@@ -9,8 +9,9 @@ import (
9 )9 )
10 10
11 type syntheticPopupState struct {11 type syntheticPopupState struct {
12- WebView port.WebView12+ WebView port.WebView
13- PendingURI string13+ PendingURI string
14+ NoJavaScriptAccess bool
14 }15 }
15 16
16 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {17 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
@@ -32,12 +33,18 @@ func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
32 return state33 return state
33 }34 }
34 35
35-func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture bool) {36+func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {
36 if wv == nil || proxyID == "" || wv.destroyed.Load() {37 if wv == nil || proxyID == "" || wv.destroyed.Load() {
37 return38 return
38 }39 }
39 40
40- _ = wv.syntheticPopupState(proxyID)41+ state := wv.syntheticPopupState(proxyID)
42+ if state != nil {
43+ wv.syntheticPopupMu.Lock()
44+ state.NoJavaScriptAccess = noJavaScriptAccess
45+ wv.syntheticPopupMu.Unlock()
46+ }
47+
41 wv.runOnGTK(func() {48 wv.runOnGTK(func() {
42 if wv.destroyed.Load() {49 if wv.destroyed.Load() {
43 return50 return
@@ -51,10 +58,11 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
51 }58 }
52 59
53 popupWV := cb.OnCreate(port.PopupRequest{60 popupWV := cb.OnCreate(port.PopupRequest{
54- TargetURI: targetURL,61+ TargetURI: targetURL,
55- FrameName: frameName,62+ FrameName: frameName,
56- IsUserGesture: userGesture,63+ IsUserGesture: userGesture,
57- ParentViewID: wv.id,64+ NoJavaScriptAccess: noJavaScriptAccess,
65+ ParentViewID: wv.id,
58 })66 })
59 if popupWV == nil {67 if popupWV == nil {
60 return68 return
@@ -71,6 +79,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
71 wv.syntheticPopups[proxyID] = state79 wv.syntheticPopups[proxyID] = state
72 }80 }
73 state.WebView = popupWV81 state.WebView = popupWV
82+ state.NoJavaScriptAccess = noJavaScriptAccess
74 pendingURI = strings.TrimSpace(state.PendingURI)83 pendingURI = strings.TrimSpace(state.PendingURI)
75 wv.syntheticPopupMu.Unlock()84 wv.syntheticPopupMu.Unlock()
76 85
@@ -123,3 +132,28 @@ func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
123 }132 }
124 })133 })
125 }134 }
135+
136+func (wv *WebView) handleSyntheticPopupClose(proxyID string) {
137+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
138+ return
139+ }
140+
141+ wv.syntheticPopupMu.Lock()
142+ state := wv.syntheticPopups[proxyID]
143+ if state != nil {
144+ delete(wv.syntheticPopups, proxyID)
145+ }
146+ wv.syntheticPopupMu.Unlock()
147+ if state == nil || state.WebView == nil {
148+ return
149+ }
150+
151+ closeable, ok := state.WebView.(port.OAuthCallbackCapable)
152+ if !ok {
153+ return
154+ }
155+
156+ wv.runOnGTK(func() {
157+ closeable.Close()
158+ })
159+}
M internal/infrastructure/cef/webview_popup_proxy_test.go +29 -2
@@ -11,6 +11,15 @@ import (
1111 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1212 )
1313
14+type closeablePopupWebViewStub struct {
15+ *portmocks.MockWebView
16+ closed bool
17+}
18+
19+func (s *closeablePopupWebViewStub) AddCloseCallback(func()) {}
20+func (s *closeablePopupWebViewStub) AddNavigationCallback(func(string)) {}
21+func (s *closeablePopupWebViewStub) Close() { s.closed = true }
22+
1423 func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
1524 popupWV := portmocks.NewMockWebView(t)
1625 popupWV.EXPECT().IsDestroyed().Return(false).Once()
@@ -24,6 +33,7 @@ func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
2433 require.Equal(t, "", req.TargetURI)
2534 require.Equal(t, "_blank", req.FrameName)
2635 require.True(t, req.IsUserGesture)
36+ require.True(t, req.NoJavaScriptAccess)
2737 require.Equal(t, port.WebViewID(17), req.ParentViewID)
2838 return popupWV
2939 },
@@ -31,7 +41,7 @@ func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
3141 }
3242
3343 parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")
34- parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true)
44+ parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true, true)
3545 }
3646
3747 func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
@@ -46,11 +56,28 @@ func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
4656 OnCreate: func(req port.PopupRequest) port.WebView {
4757 require.Equal(t, "https://example.com/start", req.TargetURI)
4858 require.Equal(t, "auth-popup", req.FrameName)
59+ require.False(t, req.NoJavaScriptAccess)
4960 return popupWV
5061 },
5162 },
5263 }
5364
54- parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true)
65+ parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true, false)
5566 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
5667 }
68+
69+func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {
70+ popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
71+ parentWV := &WebView{
72+ ctx: context.Background(),
73+ id: port.WebViewID(29),
74+ syntheticPopups: map[string]*syntheticPopupState{
75+ "popup-3": {WebView: popupWV},
76+ },
77+ }
78+
79+ parentWV.handleSyntheticPopupClose("popup-3")
80+
81+ require.True(t, popupWV.closed)
82+ require.Empty(t, parentWV.syntheticPopups)
83+}
@@ -11,6 +11,15 @@ import (
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 )12 )
13 13
14+type closeablePopupWebViewStub struct {
15+ *portmocks.MockWebView
16+ closed bool
17+}
18+
19+func (s *closeablePopupWebViewStub) AddCloseCallback(func()) {}
20+func (s *closeablePopupWebViewStub) AddNavigationCallback(func(string)) {}
21+func (s *closeablePopupWebViewStub) Close() { s.closed = true }
22+
14 func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {23 func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
15 popupWV := portmocks.NewMockWebView(t)24 popupWV := portmocks.NewMockWebView(t)
16 popupWV.EXPECT().IsDestroyed().Return(false).Once()25 popupWV.EXPECT().IsDestroyed().Return(false).Once()
@@ -24,6 +33,7 @@ func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
24 require.Equal(t, "", req.TargetURI)33 require.Equal(t, "", req.TargetURI)
25 require.Equal(t, "_blank", req.FrameName)34 require.Equal(t, "_blank", req.FrameName)
26 require.True(t, req.IsUserGesture)35 require.True(t, req.IsUserGesture)
36+ require.True(t, req.NoJavaScriptAccess)
27 require.Equal(t, port.WebViewID(17), req.ParentViewID)37 require.Equal(t, port.WebViewID(17), req.ParentViewID)
28 return popupWV38 return popupWV
29 },39 },
@@ -31,7 +41,7 @@ func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
31 }41 }
32 42
33 parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")43 parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")
34- parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true)44+ parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true, true)
35 }45 }
36 46
37 func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {47 func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
@@ -46,11 +56,28 @@ func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
46 OnCreate: func(req port.PopupRequest) port.WebView {56 OnCreate: func(req port.PopupRequest) port.WebView {
47 require.Equal(t, "https://example.com/start", req.TargetURI)57 require.Equal(t, "https://example.com/start", req.TargetURI)
48 require.Equal(t, "auth-popup", req.FrameName)58 require.Equal(t, "auth-popup", req.FrameName)
59+ require.False(t, req.NoJavaScriptAccess)
49 return popupWV60 return popupWV
50 },61 },
51 },62 },
52 }63 }
53 64
54- parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true)65+ parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true, false)
55 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")66 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
56 }67 }
68+
69+func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {
70+ popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
71+ parentWV := &WebView{
72+ ctx: context.Background(),
73+ id: port.WebViewID(29),
74+ syntheticPopups: map[string]*syntheticPopupState{
75+ "popup-3": {WebView: popupWV},
76+ },
77+ }
78+
79+ parentWV.handleSyntheticPopupClose("popup-3")
80+
81+ require.True(t, popupWV.closed)
82+ require.Empty(t, parentWV.syntheticPopups)
83+}
M internal/ui/coordinator/content/popup.go +46 -17
@@ -311,30 +311,43 @@ func (c *Coordinator) createPopupWebView(
311311 ctx context.Context,
312312 parentID port.WebViewID,
313313 targetURI string,
314+ noJavaScriptAccess bool,
314315 ) (port.WebView, string, error) {
315316 if c.factory == nil {
316317 return nil, targetURI, fmt.Errorf("no webview factory configured")
317318 }
318319
319320 log := logging.FromContext(ctx)
321+ relatedErr := error(nil)
320322
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- }
323+ if !noJavaScriptAccess {
324+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
325+ if err == nil && popupWV != nil {
326+ return popupWV, targetURI, nil
327+ }
328+ if err == nil {
329+ err = fmt.Errorf("related popup webview factory returned nil without error")
330+ }
331+ relatedErr = err
328332
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")
333+ log.Debug().
334+ Err(err).
335+ Uint64("parent_webview_id", uint64(parentID)).
336+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
337+ Msg("related popup webview unavailable, falling back to regular webview")
338+ } else {
339+ log.Debug().
340+ Uint64("parent_webview_id", uint64(parentID)).
341+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
342+ Msg("popup requested no JavaScript opener access, creating regular webview")
343+ }
334344
335345 popupWV, fallbackErr := c.factory.Create(ctx)
336346 if fallbackErr != nil {
337- return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", err, fallbackErr)
347+ if relatedErr != nil {
348+ return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
349+ }
350+ return nil, targetURI, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
338351 }
339352 if popupWV == nil {
340353 return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")
@@ -370,6 +383,7 @@ func (c *Coordinator) handlePopupCreate(
370383 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
371384 Str("frame_name", req.FrameName).
372385 Bool("user_gesture", req.IsUserGesture).
386+ Bool("no_javascript_access", req.NoJavaScriptAccess).
373387 Msg("popup create request")
374388
375389 // Check if popups are enabled
@@ -385,14 +399,16 @@ func (c *Coordinator) handlePopupCreate(
385399 }
386400
387401 parentID := parentWV.ID()
388- if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
389- return reused
402+ if !req.NoJavaScriptAccess {
403+ if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
404+ return reused
405+ }
390406 }
391407
392408 // Prefer a related WebView for session sharing, but fall back to a regular
393409 // WebView on engines like CEF OSR that do not implement native related
394410 // popup views yet.
395- popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI)
411+ popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
396412 if err != nil {
397413 log.Error().Err(err).Msg("failed to create webview for popup")
398414 return nil
@@ -492,11 +508,24 @@ func (c *Coordinator) finishPopupCreate(
492508
493509 // Register WebView in our map (after successful insertion)
494510 c.setWebViewLocked(paneID, popupWV)
495- c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
511+ if !req.NoJavaScriptAccess {
512+ c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
513+ }
496514
497515 // Setup standard callbacks (after successful insertion to avoid leak)
498516 c.setupWebViewCallbacks(ctx, paneID, popupWV)
499517
518+ // Engines without native popup lifecycle hooks can still support
519+ // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
520+ // the optional OAuthCallbackCapable interface.
521+ if _, hasNativePopupLifecycle := popupWV.(port.PopupCapable); !hasNativePopupLifecycle {
522+ if closeCapable, ok := popupWV.(port.OAuthCallbackCapable); ok {
523+ closeCapable.AddCloseCallback(func() {
524+ c.handlePopupClose(ctx, popupID)
525+ })
526+ }
527+ }
528+
500529 // Setup OAuth auto-close if configured
501530 if hasConfig && oauthEnabled && isOAuth {
502531 popupPane.AutoClose = true
@@ -311,30 +311,43 @@ func (c *Coordinator) createPopupWebView(
311 ctx context.Context,311 ctx context.Context,
312 parentID port.WebViewID,312 parentID port.WebViewID,
313 targetURI string,313 targetURI string,
314+ noJavaScriptAccess bool,
314 ) (port.WebView, string, error) {315 ) (port.WebView, string, error) {
315 if c.factory == nil {316 if c.factory == nil {
316 return nil, targetURI, fmt.Errorf("no webview factory configured")317 return nil, targetURI, fmt.Errorf("no webview factory configured")
317 }318 }
318 319
319 log := logging.FromContext(ctx)320 log := logging.FromContext(ctx)
321+ relatedErr := error(nil)
320 322
321- popupWV, err := c.factory.CreateRelated(ctx, parentID)323+ if !noJavaScriptAccess {
322- if err == nil && popupWV != nil {324+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
323- return popupWV, targetURI, nil325+ if err == nil && popupWV != nil {
324- }326+ return popupWV, targetURI, nil
325- if err == nil {327+ }
326- err = fmt.Errorf("related popup webview factory returned nil without error")328+ if err == nil {
327- }329+ err = fmt.Errorf("related popup webview factory returned nil without error")
330+ }
331+ relatedErr = err
328 332
329- log.Debug().333+ log.Debug().
330- Err(err).334+ Err(err).
331- Uint64("parent_webview_id", uint64(parentID)).335+ Uint64("parent_webview_id", uint64(parentID)).
332- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).336+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
333- Msg("related popup webview unavailable, falling back to regular webview")337+ Msg("related popup webview unavailable, falling back to regular webview")
338+ } else {
339+ log.Debug().
340+ Uint64("parent_webview_id", uint64(parentID)).
341+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
342+ Msg("popup requested no JavaScript opener access, creating regular webview")
343+ }
334 344
335 popupWV, fallbackErr := c.factory.Create(ctx)345 popupWV, fallbackErr := c.factory.Create(ctx)
336 if fallbackErr != nil {346 if fallbackErr != nil {
337- return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", err, fallbackErr)347+ if relatedErr != nil {
348+ return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
349+ }
350+ return nil, targetURI, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
338 }351 }
339 if popupWV == nil {352 if popupWV == nil {
340 return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")353 return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")
@@ -370,6 +383,7 @@ func (c *Coordinator) handlePopupCreate(
370 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).383 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
371 Str("frame_name", req.FrameName).384 Str("frame_name", req.FrameName).
372 Bool("user_gesture", req.IsUserGesture).385 Bool("user_gesture", req.IsUserGesture).
386+ Bool("no_javascript_access", req.NoJavaScriptAccess).
373 Msg("popup create request")387 Msg("popup create request")
374 388
375 // Check if popups are enabled389 // Check if popups are enabled
@@ -385,14 +399,16 @@ func (c *Coordinator) handlePopupCreate(
385 }399 }
386 400
387 parentID := parentWV.ID()401 parentID := parentWV.ID()
388- if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {402+ if !req.NoJavaScriptAccess {
389- return reused403+ if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
404+ return reused
405+ }
390 }406 }
391 407
392 // Prefer a related WebView for session sharing, but fall back to a regular408 // 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 related409 // WebView on engines like CEF OSR that do not implement native related
394 // popup views yet.410 // popup views yet.
395- popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI)411+ popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
396 if err != nil {412 if err != nil {
397 log.Error().Err(err).Msg("failed to create webview for popup")413 log.Error().Err(err).Msg("failed to create webview for popup")
398 return nil414 return nil
@@ -492,11 +508,24 @@ func (c *Coordinator) finishPopupCreate(
492 508
493 // Register WebView in our map (after successful insertion)509 // Register WebView in our map (after successful insertion)
494 c.setWebViewLocked(paneID, popupWV)510 c.setWebViewLocked(paneID, popupWV)
495- c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)511+ if !req.NoJavaScriptAccess {
512+ c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
513+ }
496 514
497 // Setup standard callbacks (after successful insertion to avoid leak)515 // Setup standard callbacks (after successful insertion to avoid leak)
498 c.setupWebViewCallbacks(ctx, paneID, popupWV)516 c.setupWebViewCallbacks(ctx, paneID, popupWV)
499 517
518+ // Engines without native popup lifecycle hooks can still support
519+ // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
520+ // the optional OAuthCallbackCapable interface.
521+ if _, hasNativePopupLifecycle := popupWV.(port.PopupCapable); !hasNativePopupLifecycle {
522+ if closeCapable, ok := popupWV.(port.OAuthCallbackCapable); ok {
523+ closeCapable.AddCloseCallback(func() {
524+ c.handlePopupClose(ctx, popupID)
525+ })
526+ }
527+ }
528+
500 // Setup OAuth auto-close if configured529 // Setup OAuth auto-close if configured
501 if hasConfig && oauthEnabled && isOAuth {530 if hasConfig && oauthEnabled && isOAuth {
502 popupPane.AutoClose = true531 popupPane.AutoClose = true
M internal/ui/coordinator/content/popup_test.go +38 -0
@@ -143,6 +143,44 @@ func TestNormalizePopupTargetURIForFallback_PreservesUnknownURLs(t *testing.T) {
143143 assert.Equal(t, raw, normalizePopupTargetURIForFallback(raw))
144144 }
145145
146+func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(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(150)).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/noopener").Return(nil).Once()
159+
160+ factory := mocks.NewMockWebViewFactory(t)
161+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
162+
163+ c := &Coordinator{
164+ webViews: make(map[entity.PaneID]port.WebView),
165+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
166+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
167+ }
168+ c.SetPopupConfig(factory, nil, nil)
169+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
170+ assert.Equal(t, "https://example.com/noopener", input.TargetURI)
171+ return nil
172+ })
173+
174+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
175+ TargetURI: "https://example.com/noopener",
176+ FrameName: "auth-popup",
177+ IsUserGesture: true,
178+ NoJavaScriptAccess: true,
179+ })
180+
181+ require.Same(t, popupWV, created)
182+}
183+
146184 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
147185 ctx := context.Background()
148186 parentPaneID := entity.PaneID("parent-pane")
@@ -143,6 +143,44 @@ func TestNormalizePopupTargetURIForFallback_PreservesUnknownURLs(t *testing.T) {
143 assert.Equal(t, raw, normalizePopupTargetURIForFallback(raw))143 assert.Equal(t, raw, normalizePopupTargetURIForFallback(raw))
144 }144 }
145 145
146+func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(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(150)).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/noopener").Return(nil).Once()
159+
160+ factory := mocks.NewMockWebViewFactory(t)
161+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
162+
163+ c := &Coordinator{
164+ webViews: make(map[entity.PaneID]port.WebView),
165+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
166+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
167+ }
168+ c.SetPopupConfig(factory, nil, nil)
169+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
170+ assert.Equal(t, "https://example.com/noopener", input.TargetURI)
171+ return nil
172+ })
173+
174+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
175+ TargetURI: "https://example.com/noopener",
176+ FrameName: "auth-popup",
177+ IsUserGesture: true,
178+ NoJavaScriptAccess: true,
179+ })
180+
181+ require.Same(t, popupWV, created)
182+}
183+
146 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {184 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
147 ctx := context.Background()185 ctx := context.Background()
148 parentPaneID := entity.PaneID("parent-pane")186 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