detached c21009b

cef: make popup fallback opener handling generic

commit
c21009b08286116f94185065688bcfb9e7719827
author
brice · · 2026-04-22 08:56 UTC
committer
brice · · 2026-04-22 12:14 UTC
parent
56c300d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +1634 -267
M go.mod +2 -0
@@ -69,3 +69,5 @@ require (
6969 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7070 modernc.org/sqlite v1.47.0 // indirect
7171 )
72+
73+replace github.com/bnema/purego-cef => ../purego-cef
@@ -69,3 +69,5 @@ require (
69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
70 modernc.org/sqlite v1.47.0 // indirect70 modernc.org/sqlite v1.47.0 // indirect
71 )71 )
72+
73+replace github.com/bnema/purego-cef => ../purego-cef
M go.sum +0 -4 generated file: collapsed
M internal/application/port/webview.go +37 -8
@@ -3,18 +3,11 @@
33 // remain independent of specific implementations (WebKit, GTK, etc.).
44 package port
55
6-import (
7- "context"
8- "errors"
9-)
6+import "context"
107
118 // WebViewID uniquely identifies a WebView instance.
129 type WebViewID uint64
1310
14-// ErrRelatedWebViewUnsupported reports that the active engine cannot create
15-// a popup WebView related to an existing parent WebView.
16-var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
17-
1811 // LoadEvent represents page load state transitions.
1912 type LoadEvent int
2013
@@ -330,6 +323,42 @@ type PopupCapable interface {
330323 Show()
331324 }
332325
326+// PopupNavigationCapable is implemented by WebViews that need the popup target
327+// URI primed before the browser is fully created. Engines with native popup
328+// creation can ignore this capability; engines that create popup shells and
329+// attach or create browsers later can use it to preserve a unified popup
330+// lifecycle without coordinator-specific fallback logic.
331+type PopupNavigationCapable interface {
332+ PrimePopupNavigation(uri string)
333+}
334+
335+// PopupOpenerBridgeCapable is implemented by WebViews that can emulate
336+// opener semantics when a popup must be created directly instead of through a
337+// native related-popup lifecycle. Engines can ignore this capability when they
338+// provide real popup opener relationships.
339+type PopupOpenerBridgeCapable interface {
340+ EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)
341+}
342+
343+// PopupOpenerMessageCapable is implemented by popup WebViews that can observe
344+// synthetic `window.opener.postMessage(...)` traffic in fallback popup flows.
345+type PopupOpenerMessageCapable interface {
346+ AddOpenerMessageCallback(fn func())
347+}
348+
349+// PopupOpenerNavigationCapable is implemented by popup WebViews that can observe
350+// synthetic `window.opener.location = ...` navigations in fallback popup flows.
351+type PopupOpenerNavigationCapable interface {
352+ AddOpenerNavigationCallback(fn func(uri string))
353+}
354+
355+// PopupOpenerBridgeStateCapable is implemented by popup WebViews that can
356+// report whether a synthetic opener bridge is actively installed for the
357+// current popup instance.
358+type PopupOpenerBridgeStateCapable interface {
359+ HasActivePopupOpenerBridge() bool
360+}
361+
333362 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
334363 // It is an optional capability; callers should type-assert port.WebView to this interface
335364 // before use and degrade gracefully when it is absent.
@@ -3,18 +3,11 @@
3 // remain independent of specific implementations (WebKit, GTK, etc.).3 // remain independent of specific implementations (WebKit, GTK, etc.).
4 package port4 package port
5 5
6-import (6+import "context"
7- "context"
8- "errors"
9-)
10 7
11 // WebViewID uniquely identifies a WebView instance.8 // WebViewID uniquely identifies a WebView instance.
12 type WebViewID uint649 type WebViewID uint64
13 10
14-// ErrRelatedWebViewUnsupported reports that the active engine cannot create
15-// a popup WebView related to an existing parent WebView.
16-var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
17-
18 // LoadEvent represents page load state transitions.11 // LoadEvent represents page load state transitions.
19 type LoadEvent int12 type LoadEvent int
20 13
@@ -330,6 +323,42 @@ type PopupCapable interface {
330 Show()323 Show()
331 }324 }
332 325
326+// PopupNavigationCapable is implemented by WebViews that need the popup target
327+// URI primed before the browser is fully created. Engines with native popup
328+// creation can ignore this capability; engines that create popup shells and
329+// attach or create browsers later can use it to preserve a unified popup
330+// lifecycle without coordinator-specific fallback logic.
331+type PopupNavigationCapable interface {
332+ PrimePopupNavigation(uri string)
333+}
334+
335+// PopupOpenerBridgeCapable is implemented by WebViews that can emulate
336+// opener semantics when a popup must be created directly instead of through a
337+// native related-popup lifecycle. Engines can ignore this capability when they
338+// provide real popup opener relationships.
339+type PopupOpenerBridgeCapable interface {
340+ EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)
341+}
342+
343+// PopupOpenerMessageCapable is implemented by popup WebViews that can observe
344+// synthetic `window.opener.postMessage(...)` traffic in fallback popup flows.
345+type PopupOpenerMessageCapable interface {
346+ AddOpenerMessageCallback(fn func())
347+}
348+
349+// PopupOpenerNavigationCapable is implemented by popup WebViews that can observe
350+// synthetic `window.opener.location = ...` navigations in fallback popup flows.
351+type PopupOpenerNavigationCapable interface {
352+ AddOpenerNavigationCallback(fn func(uri string))
353+}
354+
355+// PopupOpenerBridgeStateCapable is implemented by popup WebViews that can
356+// report whether a synthetic opener bridge is actively installed for the
357+// current popup instance.
358+type PopupOpenerBridgeStateCapable interface {
359+ HasActivePopupOpenerBridge() bool
360+}
361+
333 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.362 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
334 // It is an optional capability; callers should type-assert port.WebView to this interface363 // It is an optional capability; callers should type-assert port.WebView to this interface
335 // before use and degrade gracefully when it is absent.364 // before use and degrade gracefully when it is absent.
A internal/domain/errors/errors.go +7 -0
@@ -0,0 +1,7 @@
1+package domainerrors
2+
3+import "errors"
4+
5+// ErrRelatedWebViewUnsupported reports that the active engine cannot create
6+// a popup WebView related to an existing parent WebView.
7+var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
@@ -0,0 +1,7 @@
1+package domainerrors
2+
3+import "errors"
4+
5+// ErrRelatedWebViewUnsupported reports that the active engine cannot create
6+// a popup WebView related to an existing parent WebView.
7+var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
M internal/infrastructure/cef/content_injector.go +9 -2
@@ -707,7 +707,7 @@ const popupFetchBridgeJS = `(function() {
707707 focus: function() { return undefined; },
708708 postMessage: function() { return undefined; }
709709 };
710- proxy.opener = popupHasNoOpener(features) ? null : window;
710+ proxy.opener = noJavaScriptAccess ? null : window;
711711 proxy.self = proxy;
712712 proxy.window = proxy;
713713
@@ -733,6 +733,13 @@ const popupFetchBridgeJS = `(function() {
733733 proxy.location = locationProxy;
734734 }
735735
736+ try {
737+ Object.defineProperty(proxy, Symbol.toStringTag, {
738+ configurable: true,
739+ value: 'Window'
740+ });
741+ } catch (_) {}
742+
736743 return proxy;
737744 }
738745
@@ -892,7 +899,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
892899
893900 // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
894901 // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
895- bridgeNonce := wv.rotateBridgeNonce()
902+ bridgeNonce := wv.ensureBridgeNonce()
896903 if bridgeNonce == "" {
897904 ctx := context.Background()
898905 if ci != nil && ci.engine != nil {
@@ -707,7 +707,7 @@ const popupFetchBridgeJS = `(function() {
707 focus: function() { return undefined; },707 focus: function() { return undefined; },
708 postMessage: function() { return undefined; }708 postMessage: function() { return undefined; }
709 };709 };
710- proxy.opener = popupHasNoOpener(features) ? null : window;710+ proxy.opener = noJavaScriptAccess ? null : window;
711 proxy.self = proxy;711 proxy.self = proxy;
712 proxy.window = proxy;712 proxy.window = proxy;
713 713
@@ -733,6 +733,13 @@ const popupFetchBridgeJS = `(function() {
733 proxy.location = locationProxy;733 proxy.location = locationProxy;
734 }734 }
735 735
736+ try {
737+ Object.defineProperty(proxy, Symbol.toStringTag, {
738+ configurable: true,
739+ value: 'Window'
740+ });
741+ } catch (_) {}
742+
736 return proxy;743 return proxy;
737 }744 }
738 745
@@ -892,7 +899,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
892 899
893 // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still900 // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
894 // need a JS bridge in OSR mode while the native renderer bridge remains disabled.901 // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
895- bridgeNonce := wv.rotateBridgeNonce()902+ bridgeNonce := wv.ensureBridgeNonce()
896 if bridgeNonce == "" {903 if bridgeNonce == "" {
897 ctx := context.Background()904 ctx := context.Background()
898 if ci != nil && ci.engine != nil {905 if ci != nil && ci.engine != nil {
M internal/infrastructure/cef/content_injector_test.go +16 -0
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/stretchr/testify/require"
@@ -81,3 +82,18 @@ func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
8182 require.Contains(t, script, "test-bridge-nonce")
8283 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
8384 }
85+
86+func TestPopupOpenerBridgeScript_InstallsSyntheticOpenerCallbacks(t *testing.T) {
87+ parent := &WebView{ctx: context.Background()}
88+ parent.updateURI("https://example.com/app")
89+ popup := &WebView{ctx: context.Background(), popupOpenerBridgeParent: parent, popupOpenerBridgeParentURI: "https://example.com/app"}
90+
91+ script := popup.popupOpenerBridgeScript("bridge-nonce")
92+
93+ require.Contains(t, script, "window.__dumberPopupOpenerBridgeInstalled")
94+ require.Contains(t, script, "popup-opener-navigate")
95+ require.Contains(t, script, "popup-opener-post-message")
96+ require.Contains(t, script, "Object.defineProperty(window, 'opener'")
97+ require.Contains(t, script, "https://example.com/app")
98+ require.Contains(t, script, "bridge-nonce")
99+}
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -81,3 +82,18 @@ func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
81 require.Contains(t, script, "test-bridge-nonce")82 require.Contains(t, script, "test-bridge-nonce")
82 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")83 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
83 }84 }
85+
86+func TestPopupOpenerBridgeScript_InstallsSyntheticOpenerCallbacks(t *testing.T) {
87+ parent := &WebView{ctx: context.Background()}
88+ parent.updateURI("https://example.com/app")
89+ popup := &WebView{ctx: context.Background(), popupOpenerBridgeParent: parent, popupOpenerBridgeParentURI: "https://example.com/app"}
90+
91+ script := popup.popupOpenerBridgeScript("bridge-nonce")
92+
93+ require.Contains(t, script, "window.__dumberPopupOpenerBridgeInstalled")
94+ require.Contains(t, script, "popup-opener-navigate")
95+ require.Contains(t, script, "popup-opener-post-message")
96+ require.Contains(t, script, "Object.defineProperty(window, 'opener'")
97+ require.Contains(t, script, "https://example.com/app")
98+ require.Contains(t, script, "bridge-nonce")
99+}
M internal/infrastructure/cef/engine.go +18 -1
@@ -146,11 +146,28 @@ func (e *Engine) registerWebView(wv *WebView) {
146146 e.activeCount.Add(1)
147147 }
148148
149+func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
150+ if e == nil {
151+ return nil
152+ }
153+ current, ok := e.activeWebViews.Load(id)
154+ if !ok {
155+ return nil
156+ }
157+ wv, _ := current.(*WebView)
158+ return wv
159+}
160+
149161 func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
150162 if e == nil || browser == nil || wv == nil {
151163 return
152164 }
153- e.browserWebViews.Store(browser.GetIdentifier(), wv)
165+ browserID := browser.GetIdentifier()
166+ if browserID <= 0 {
167+ logging.FromContext(e.ctx).Debug().Int32("browser_id", browserID).Msg("cef: skipped browser/webview binding for invalid browser id")
168+ return
169+ }
170+ e.browserWebViews.Store(browserID, wv)
154171 }
155172
156173 func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
@@ -146,11 +146,28 @@ func (e *Engine) registerWebView(wv *WebView) {
146 e.activeCount.Add(1)146 e.activeCount.Add(1)
147 }147 }
148 148
149+func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
150+ if e == nil {
151+ return nil
152+ }
153+ current, ok := e.activeWebViews.Load(id)
154+ if !ok {
155+ return nil
156+ }
157+ wv, _ := current.(*WebView)
158+ return wv
159+}
160+
149 func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {161 func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
150 if e == nil || browser == nil || wv == nil {162 if e == nil || browser == nil || wv == nil {
151 return163 return
152 }164 }
153- e.browserWebViews.Store(browser.GetIdentifier(), wv)165+ browserID := browser.GetIdentifier()
166+ if browserID <= 0 {
167+ logging.FromContext(e.ctx).Debug().Int32("browser_id", browserID).Msg("cef: skipped browser/webview binding for invalid browser id")
168+ return
169+ }
170+ e.browserWebViews.Store(browserID, wv)
154 }171 }
155 172
156 func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {173 func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
M internal/infrastructure/cef/engine_init.go +2 -0
@@ -261,6 +261,8 @@ func wireEngine(
261261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
263263 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
264+ schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
265+ schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
264266 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
265267
266268 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -261,6 +261,8 @@ func wireEngine(
261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen261 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate262 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
263 schemeHandler.onPopupClose = eng.handlePopupBridgeClose263 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
264+ schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
265+ schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
264 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest266 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
265 267
266 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)268 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
M internal/infrastructure/cef/engine_popup_bridge.go +12 -0
@@ -75,3 +75,15 @@ func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendere
7575 wv.handleSyntheticPopupClose(payload.ProxyID)
7676 })
7777 }
78+
79+func (e *Engine) handlePopupOpenerNavigate(browser purecef.Browser, payload popupOpenerNavigatePayload) {
80+ e.withBridgeSourceWebView(browser, "", "popup-opener-navigate", func(wv *WebView) {
81+ wv.handlePopupOpenerNavigate(payload.URL)
82+ })
83+}
84+
85+func (e *Engine) handlePopupOpenerPostMessage(browser purecef.Browser, payload popupOpenerPostMessagePayload) {
86+ e.withBridgeSourceWebView(browser, "", "popup-opener-post-message", func(wv *WebView) {
87+ wv.handlePopupOpenerPostMessage(payload)
88+ })
89+}
@@ -75,3 +75,15 @@ func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendere
75 wv.handleSyntheticPopupClose(payload.ProxyID)75 wv.handleSyntheticPopupClose(payload.ProxyID)
76 })76 })
77 }77 }
78+
79+func (e *Engine) handlePopupOpenerNavigate(browser purecef.Browser, payload popupOpenerNavigatePayload) {
80+ e.withBridgeSourceWebView(browser, "", "popup-opener-navigate", func(wv *WebView) {
81+ wv.handlePopupOpenerNavigate(payload.URL)
82+ })
83+}
84+
85+func (e *Engine) handlePopupOpenerPostMessage(browser purecef.Browser, payload popupOpenerPostMessagePayload) {
86+ e.withBridgeSourceWebView(browser, "", "popup-opener-post-message", func(wv *WebView) {
87+ wv.handlePopupOpenerPostMessage(payload)
88+ })
89+}
M internal/infrastructure/cef/factory.go +153 -90
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "fmt"
56 "sync/atomic"
67
78 purecef "github.com/bnema/purego-cef/cef"
@@ -41,6 +42,8 @@ type resizeNotifiableBrowserHost interface {
4142 Invalidate(purecef.PaintElementType)
4243 }
4344
45+var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser
46+
4447 // newWebViewFactory returns a factory that will create WebViews using the
4548 // given GL loader and HiDPI scale factor.
4649 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -72,17 +75,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7275 // asynchronously; the returned WebView is usable immediately but navigation
7376 // will fail with errNoBrowser until OnAfterCreated fires.
7477 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
75- id := port.WebViewID(f.nextID.Add(1))
78+ wv := f.newWebView(ctx)
7679
80+ // Configure WindowInfo for off-screen rendering (OSR).
81+ windowInfo := purecef.NewWindowInfo()
82+ windowInfo.WindowlessRenderingEnabled = 1
83+ if externalBeginFrameEnabled() {
84+ windowInfo.ExternalBeginFrameEnabled = 1
85+ }
86+
87+ // Configure BrowserSettings.
88+ settings := purecef.NewBrowserSettings()
89+ settings.WindowlessFrameRate = f.windowlessFrameRate
90+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
91+ if bg := f.bgColor.Load(); bg != 0 {
92+ settings.BackgroundColor = bg
93+ }
94+
95+ // Defer browser creation until the GL area has a non-zero size.
96+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
97+ // is not yet realized at this point.
98+ f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings)
99+
100+ return wv, nil
101+}
102+
103+func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
104+ id := port.WebViewID(f.nextID.Add(1))
77105 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)
78106
79107 wv := &WebView{
80- id: id,
81- ctx: ctx,
82- engine: f.engine,
83- pipeline: pipeline,
84- audioOutputFactory: f.audioOutputFactory,
85- resizeReconciler: newResizeReconciler(ctx, id),
108+ id: id,
109+ ctx: ctx,
110+ engine: f.engine,
111+ pipeline: pipeline,
112+ audioOutputFactory: f.audioOutputFactory,
113+ resizeReconciler: newResizeReconciler(ctx, id),
114+ windowlessFrameRate: f.windowlessFrameRate,
115+ backgroundColor: f.bgColor.Load(),
86116 }
87117
88118 var transcodingHandler purecef.ResourceRequestHandler
@@ -129,30 +159,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
129159
130160 // Build a CEF client backed by our handlerSet.
131161 // Store on WebView to prevent GC collection before CEF AddRef's it.
132- client := purecef.NewClient(wv.handlers)
133- wv.client = client
134-
135- // Configure WindowInfo for off-screen rendering (OSR).
136- windowInfo := purecef.NewWindowInfo()
137- windowInfo.WindowlessRenderingEnabled = 1
138- if externalBeginFrameEnabled() {
139- windowInfo.ExternalBeginFrameEnabled = 1
140- }
141-
142- // Configure BrowserSettings.
143- settings := purecef.NewBrowserSettings()
144- settings.WindowlessFrameRate = f.windowlessFrameRate
145- settings.LocalStorage = 1 // CEF_STATE_ENABLED
146- if bg := f.bgColor.Load(); bg != 0 {
147- settings.BackgroundColor = bg
148- }
149-
150- // Defer browser creation until the GL area has a non-zero size.
151- // CEF requires GetViewRect to return a non-empty rect, but the GL area
152- // is not yet realized at this point.
153- f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings)
154-
155- return wv, nil
162+ wv.client = purecef.NewClient(wv.handlers)
163+ return wv
156164 }
157165
158166 func (f *WebViewFactory) configureInitialBrowserCreation(
@@ -166,60 +174,8 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
166174 }
167175
168176 pipeline.onFirstResize = func(w, h int32) {
169- log := logging.FromContext(ctx)
170- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
171- pc := wv.takePendingCreate()
172- if pc == nil {
173- return
174- }
175-
176- task := purecef.NewTask(cefTaskFunc(func() {
177- if wv.destroyed.Load() {
178- log.Debug().Msg("cef: skipping browser creation for destroyed webview")
179- return
180- }
181- pendingURL := wv.pendingNavigationURI()
182- // Always bootstrap OSR browsers on about:blank first. Starting directly on
183- // the target URL can race host visibility/focus setup and strand the
184- // renderer without an initial paint. We replay pending navigation from
185- // OnAfterCreated once the host is fully wired.
186- initialURL := "about:blank"
187- result := purecef.BrowserHostCreateBrowser(
188- pc.windowInfo,
189- pc.client,
190- initialURL,
191- pc.settings,
192- nil, // extraInfo
193- nil, // requestContext
194- )
195- log.Debug().
196- Str("initial_url", initialURL).
197- Str("pending_url", pendingURL).
198- Msg("cef: BrowserHostCreateBrowser initial URL")
199- if f.engine != nil {
200- f.engine.recordBrowserCreateRequest(w, h, result)
201- }
202- log.Debug().
203- Int32("result", result).
204- Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
205- Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
206- Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
207- Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
208- Bool("client_nil", pc.client == nil).
209- Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
210- }))
211-
212- postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
213- if postResult != 1 {
214- wv.mu.Lock()
215- if wv.pendingCreate == nil {
216- wv.pendingCreate = pc
217- }
218- wv.mu.Unlock()
219- log.Error().
220- Int32("result", postResult).
221- Msg("cef: failed to post initial browser creation to CEF UI thread")
222- }
177+ logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
178+ f.postPendingBrowserCreate(ctx, wv, w, h)
223179 }
224180
225181 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
@@ -268,6 +224,62 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
268224 }
269225 }
270226
227+func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
228+ log := logging.FromContext(ctx)
229+ pc := wv.takePendingCreate()
230+ if pc == nil {
231+ return
232+ }
233+
234+ task := purecef.NewTask(cefTaskFunc(func() {
235+ if wv.destroyed.Load() {
236+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
237+ return
238+ }
239+ pendingURL := wv.pendingNavigationURI()
240+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
241+ // the target URL can race host visibility/focus setup and strand the
242+ // renderer without an initial paint. We replay pending navigation from
243+ // OnAfterCreated once the host is fully wired.
244+ initialURL := "about:blank"
245+ result := cefBrowserHostCreateBrowser(
246+ pc.windowInfo,
247+ pc.client,
248+ initialURL,
249+ pc.settings,
250+ nil, // extraInfo
251+ nil, // requestContext
252+ )
253+ log.Debug().
254+ Str("initial_url", initialURL).
255+ Str("pending_url", pendingURL).
256+ Msg("cef: BrowserHostCreateBrowser initial URL")
257+ if f.engine != nil {
258+ f.engine.recordBrowserCreateRequest(w, h, result)
259+ }
260+ log.Debug().
261+ Int32("result", result).
262+ Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
263+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
264+ Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
265+ Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
266+ Bool("client_nil", pc.client == nil).
267+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
268+ }))
269+
270+ postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
271+ if postResult != 1 {
272+ wv.mu.Lock()
273+ if wv.pendingCreate == nil {
274+ wv.pendingCreate = pc
275+ }
276+ wv.mu.Unlock()
277+ log.Error().
278+ Int32("result", postResult).
279+ Msg("cef: failed to post initial browser creation to CEF UI thread")
280+ }
281+}
282+
271283 func notifyBrowserResize(host resizeNotifiableBrowserHost) {
272284 if host == nil {
273285 return
@@ -276,9 +288,60 @@ func notifyBrowserResize(host resizeNotifiableBrowserHost) {
276288 host.Invalidate(purecef.PaintElementTypePetView)
277289 }
278290
279-// CreateRelated creates a WebView that shares session/cookies with the parent.
280-// TODO(phase2): look up the parent browser by parentID and use the same
281-// request context so cookies and session state are shared.
282-func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
283- return nil, ErrRelatedWebViewUnsupported
291+// CreateRelated creates a popup shell for a native CEF popup. The popup
292+// browser itself is created by CEF from OnBeforePopup and later bound to this
293+// shell in OnAfterCreated.
294+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
295+ if f == nil || f.engine == nil {
296+ return nil, ErrRelatedWebViewUnsupported
297+ }
298+
299+ parent := f.engine.lookupWebView(parentID)
300+ if parent == nil {
301+ return nil, fmt.Errorf("parent webview %d not found", parentID)
302+ }
303+ if parent.destroyed.Load() {
304+ return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
305+ }
306+
307+ popupWV := f.newWebView(ctx)
308+ popupWV.markNativePopupCandidate(parent)
309+
310+ windowInfo := purecef.NewWindowInfo()
311+ windowInfo.WindowlessRenderingEnabled = 1
312+ if externalBeginFrameEnabled() {
313+ windowInfo.ExternalBeginFrameEnabled = 1
314+ }
315+
316+ settings := purecef.NewBrowserSettings()
317+ settings.WindowlessFrameRate = f.windowlessFrameRate
318+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
319+ if bg := f.bgColor.Load(); bg != 0 {
320+ settings.BackgroundColor = bg
321+ }
322+
323+ f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings)
324+ popupWV.pipeline.onFirstResize = func(w, h int32) {
325+ log := logging.FromContext(ctx)
326+ if popupWV.awaitsNativePopupAttachment() {
327+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")
328+ popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
329+ if !popupWV.startNativePopupFallback() {
330+ return
331+ }
332+ logging.FromContext(ctx).Warn().
333+ Uint64("webview_id", uint64(popupWV.id)).
334+ Msg("cef: native popup attach timed out, creating popup browser directly")
335+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
336+ })
337+ return
338+ }
339+
340+ if !popupWV.preparePopupShellDirectBrowserCreation() {
341+ return
342+ }
343+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
344+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
345+ }
346+ return popupWV, nil
284347 }
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "sync/atomic"6 "sync/atomic"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
@@ -41,6 +42,8 @@ type resizeNotifiableBrowserHost interface {
41 Invalidate(purecef.PaintElementType)42 Invalidate(purecef.PaintElementType)
42 }43 }
43 44
45+var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser
46+
44 // newWebViewFactory returns a factory that will create WebViews using the47 // newWebViewFactory returns a factory that will create WebViews using the
45 // given GL loader and HiDPI scale factor.48 // given GL loader and HiDPI scale factor.
46 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {49 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -72,17 +75,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
72 // asynchronously; the returned WebView is usable immediately but navigation75 // asynchronously; the returned WebView is usable immediately but navigation
73 // will fail with errNoBrowser until OnAfterCreated fires.76 // will fail with errNoBrowser until OnAfterCreated fires.
74 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {77 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
75- id := port.WebViewID(f.nextID.Add(1))78+ wv := f.newWebView(ctx)
76 79
80+ // Configure WindowInfo for off-screen rendering (OSR).
81+ windowInfo := purecef.NewWindowInfo()
82+ windowInfo.WindowlessRenderingEnabled = 1
83+ if externalBeginFrameEnabled() {
84+ windowInfo.ExternalBeginFrameEnabled = 1
85+ }
86+
87+ // Configure BrowserSettings.
88+ settings := purecef.NewBrowserSettings()
89+ settings.WindowlessFrameRate = f.windowlessFrameRate
90+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
91+ if bg := f.bgColor.Load(); bg != 0 {
92+ settings.BackgroundColor = bg
93+ }
94+
95+ // Defer browser creation until the GL area has a non-zero size.
96+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
97+ // is not yet realized at this point.
98+ f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings)
99+
100+ return wv, nil
101+}
102+
103+func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
104+ id := port.WebViewID(f.nextID.Add(1))
77 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)105 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)
78 106
79 wv := &WebView{107 wv := &WebView{
80- id: id,108+ id: id,
81- ctx: ctx,109+ ctx: ctx,
82- engine: f.engine,110+ engine: f.engine,
83- pipeline: pipeline,111+ pipeline: pipeline,
84- audioOutputFactory: f.audioOutputFactory,112+ audioOutputFactory: f.audioOutputFactory,
85- resizeReconciler: newResizeReconciler(ctx, id),113+ resizeReconciler: newResizeReconciler(ctx, id),
114+ windowlessFrameRate: f.windowlessFrameRate,
115+ backgroundColor: f.bgColor.Load(),
86 }116 }
87 117
88 var transcodingHandler purecef.ResourceRequestHandler118 var transcodingHandler purecef.ResourceRequestHandler
@@ -129,30 +159,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
129 159
130 // Build a CEF client backed by our handlerSet.160 // Build a CEF client backed by our handlerSet.
131 // Store on WebView to prevent GC collection before CEF AddRef's it.161 // Store on WebView to prevent GC collection before CEF AddRef's it.
132- client := purecef.NewClient(wv.handlers)162+ wv.client = purecef.NewClient(wv.handlers)
133- wv.client = client163+ return wv
134-
135- // Configure WindowInfo for off-screen rendering (OSR).
136- windowInfo := purecef.NewWindowInfo()
137- windowInfo.WindowlessRenderingEnabled = 1
138- if externalBeginFrameEnabled() {
139- windowInfo.ExternalBeginFrameEnabled = 1
140- }
141-
142- // Configure BrowserSettings.
143- settings := purecef.NewBrowserSettings()
144- settings.WindowlessFrameRate = f.windowlessFrameRate
145- settings.LocalStorage = 1 // CEF_STATE_ENABLED
146- if bg := f.bgColor.Load(); bg != 0 {
147- settings.BackgroundColor = bg
148- }
149-
150- // Defer browser creation until the GL area has a non-zero size.
151- // CEF requires GetViewRect to return a non-empty rect, but the GL area
152- // is not yet realized at this point.
153- f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings)
154-
155- return wv, nil
156 }164 }
157 165
158 func (f *WebViewFactory) configureInitialBrowserCreation(166 func (f *WebViewFactory) configureInitialBrowserCreation(
@@ -166,60 +174,8 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
166 }174 }
167 175
168 pipeline.onFirstResize = func(w, h int32) {176 pipeline.onFirstResize = func(w, h int32) {
169- log := logging.FromContext(ctx)177+ logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
170- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")178+ f.postPendingBrowserCreate(ctx, wv, w, h)
171- pc := wv.takePendingCreate()
172- if pc == nil {
173- return
174- }
175-
176- task := purecef.NewTask(cefTaskFunc(func() {
177- if wv.destroyed.Load() {
178- log.Debug().Msg("cef: skipping browser creation for destroyed webview")
179- return
180- }
181- pendingURL := wv.pendingNavigationURI()
182- // Always bootstrap OSR browsers on about:blank first. Starting directly on
183- // the target URL can race host visibility/focus setup and strand the
184- // renderer without an initial paint. We replay pending navigation from
185- // OnAfterCreated once the host is fully wired.
186- initialURL := "about:blank"
187- result := purecef.BrowserHostCreateBrowser(
188- pc.windowInfo,
189- pc.client,
190- initialURL,
191- pc.settings,
192- nil, // extraInfo
193- nil, // requestContext
194- )
195- log.Debug().
196- Str("initial_url", initialURL).
197- Str("pending_url", pendingURL).
198- Msg("cef: BrowserHostCreateBrowser initial URL")
199- if f.engine != nil {
200- f.engine.recordBrowserCreateRequest(w, h, result)
201- }
202- log.Debug().
203- Int32("result", result).
204- Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
205- Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
206- Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
207- Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
208- Bool("client_nil", pc.client == nil).
209- Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
210- }))
211-
212- postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
213- if postResult != 1 {
214- wv.mu.Lock()
215- if wv.pendingCreate == nil {
216- wv.pendingCreate = pc
217- }
218- wv.mu.Unlock()
219- log.Error().
220- Int32("result", postResult).
221- Msg("cef: failed to post initial browser creation to CEF UI thread")
222- }
223 }179 }
224 180
225 // On subsequent resizes, notify CEF so it re-queries GetViewRect.181 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
@@ -268,6 +224,62 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
268 }224 }
269 }225 }
270 226
227+func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
228+ log := logging.FromContext(ctx)
229+ pc := wv.takePendingCreate()
230+ if pc == nil {
231+ return
232+ }
233+
234+ task := purecef.NewTask(cefTaskFunc(func() {
235+ if wv.destroyed.Load() {
236+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
237+ return
238+ }
239+ pendingURL := wv.pendingNavigationURI()
240+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
241+ // the target URL can race host visibility/focus setup and strand the
242+ // renderer without an initial paint. We replay pending navigation from
243+ // OnAfterCreated once the host is fully wired.
244+ initialURL := "about:blank"
245+ result := cefBrowserHostCreateBrowser(
246+ pc.windowInfo,
247+ pc.client,
248+ initialURL,
249+ pc.settings,
250+ nil, // extraInfo
251+ nil, // requestContext
252+ )
253+ log.Debug().
254+ Str("initial_url", initialURL).
255+ Str("pending_url", pendingURL).
256+ Msg("cef: BrowserHostCreateBrowser initial URL")
257+ if f.engine != nil {
258+ f.engine.recordBrowserCreateRequest(w, h, result)
259+ }
260+ log.Debug().
261+ Int32("result", result).
262+ Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
263+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
264+ Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
265+ Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
266+ Bool("client_nil", pc.client == nil).
267+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
268+ }))
269+
270+ postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
271+ if postResult != 1 {
272+ wv.mu.Lock()
273+ if wv.pendingCreate == nil {
274+ wv.pendingCreate = pc
275+ }
276+ wv.mu.Unlock()
277+ log.Error().
278+ Int32("result", postResult).
279+ Msg("cef: failed to post initial browser creation to CEF UI thread")
280+ }
281+}
282+
271 func notifyBrowserResize(host resizeNotifiableBrowserHost) {283 func notifyBrowserResize(host resizeNotifiableBrowserHost) {
272 if host == nil {284 if host == nil {
273 return285 return
@@ -276,9 +288,60 @@ func notifyBrowserResize(host resizeNotifiableBrowserHost) {
276 host.Invalidate(purecef.PaintElementTypePetView)288 host.Invalidate(purecef.PaintElementTypePetView)
277 }289 }
278 290
279-// CreateRelated creates a WebView that shares session/cookies with the parent.291+// CreateRelated creates a popup shell for a native CEF popup. The popup
280-// TODO(phase2): look up the parent browser by parentID and use the same292+// browser itself is created by CEF from OnBeforePopup and later bound to this
281-// request context so cookies and session state are shared.293+// shell in OnAfterCreated.
282-func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {294+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
283- return nil, ErrRelatedWebViewUnsupported295+ if f == nil || f.engine == nil {
296+ return nil, ErrRelatedWebViewUnsupported
297+ }
298+
299+ parent := f.engine.lookupWebView(parentID)
300+ if parent == nil {
301+ return nil, fmt.Errorf("parent webview %d not found", parentID)
302+ }
303+ if parent.destroyed.Load() {
304+ return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
305+ }
306+
307+ popupWV := f.newWebView(ctx)
308+ popupWV.markNativePopupCandidate(parent)
309+
310+ windowInfo := purecef.NewWindowInfo()
311+ windowInfo.WindowlessRenderingEnabled = 1
312+ if externalBeginFrameEnabled() {
313+ windowInfo.ExternalBeginFrameEnabled = 1
314+ }
315+
316+ settings := purecef.NewBrowserSettings()
317+ settings.WindowlessFrameRate = f.windowlessFrameRate
318+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
319+ if bg := f.bgColor.Load(); bg != 0 {
320+ settings.BackgroundColor = bg
321+ }
322+
323+ f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings)
324+ popupWV.pipeline.onFirstResize = func(w, h int32) {
325+ log := logging.FromContext(ctx)
326+ if popupWV.awaitsNativePopupAttachment() {
327+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")
328+ popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
329+ if !popupWV.startNativePopupFallback() {
330+ return
331+ }
332+ logging.FromContext(ctx).Warn().
333+ Uint64("webview_id", uint64(popupWV.id)).
334+ Msg("cef: native popup attach timed out, creating popup browser directly")
335+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
336+ })
337+ return
338+ }
339+
340+ if !popupWV.preparePopupShellDirectBrowserCreation() {
341+ return
342+ }
343+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
344+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
345+ }
346+ return popupWV, nil
284 }347 }
A internal/infrastructure/cef/popup_opener_bridge.go +427 -0
@@ -0,0 +1,427 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ neturl "net/url"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/infrastructure/webutil"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+type popupOpenerPostMessagePayload struct {
15+ Data string `json:"data"`
16+ DataKind string `json:"data_kind"`
17+ TargetOrigin string `json:"target_origin"`
18+ SourceOrigin string `json:"source_origin"`
19+ SourceHref string `json:"source_href"`
20+}
21+
22+type popupOpenerNavigatePayload struct {
23+ URL string `json:"url"`
24+}
25+
26+func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) {
27+ if wv == nil {
28+ return
29+ }
30+ wv.mu.Lock()
31+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
32+ wv.mu.Unlock()
33+}
34+
35+// AddOpenerMessageCallback implements port.PopupOpenerMessageCapable.
36+func (wv *WebView) AddOpenerMessageCallback(fn func()) {
37+ if wv == nil || fn == nil {
38+ return
39+ }
40+ wv.mu.Lock()
41+ wv.openerMessageCallbacks = append(wv.openerMessageCallbacks, fn)
42+ wv.mu.Unlock()
43+}
44+
45+// AddOpenerNavigationCallback implements port.PopupOpenerNavigationCapable.
46+func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {
47+ if wv == nil || fn == nil {
48+ return
49+ }
50+ wv.mu.Lock()
51+ wv.openerNavigationCallbacks = append(wv.openerNavigationCallbacks, fn)
52+ wv.mu.Unlock()
53+}
54+
55+func (wv *WebView) runOpenerMessageCallbacks() {
56+ if wv == nil {
57+ return
58+ }
59+ wv.mu.RLock()
60+ callbacks := append([]func(){}, wv.openerMessageCallbacks...)
61+ wv.mu.RUnlock()
62+ if len(callbacks) == 0 {
63+ return
64+ }
65+ wv.runOnGTK(func() {
66+ for _, fn := range callbacks {
67+ if fn != nil {
68+ fn()
69+ }
70+ }
71+ })
72+}
73+
74+func (wv *WebView) runOpenerNavigationCallbacks(uri string) {
75+ if wv == nil {
76+ return
77+ }
78+ wv.mu.RLock()
79+ callbacks := append([]func(string){}, wv.openerNavigationCallbacks...)
80+ wv.mu.RUnlock()
81+ if len(callbacks) == 0 {
82+ return
83+ }
84+ wv.runOnGTK(func() {
85+ for _, fn := range callbacks {
86+ if fn != nil {
87+ fn(uri)
88+ }
89+ }
90+ })
91+}
92+
93+// HasActivePopupOpenerBridge implements port.PopupOpenerBridgeStateCapable.
94+func (wv *WebView) HasActivePopupOpenerBridge() bool {
95+ if wv == nil {
96+ return false
97+ }
98+ wv.mu.RLock()
99+ defer wv.mu.RUnlock()
100+ return !wv.popupNoJavaScriptAccess && wv.popupOpenerBridgeParent != nil
101+}
102+
103+// EnablePopupOpenerBridge implements port.PopupOpenerBridgeCapable.
104+func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
105+ if wv == nil {
106+ return
107+ }
108+ wv.mu.Lock()
109+ defer wv.mu.Unlock()
110+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
111+ if noJavaScriptAccess {
112+ wv.popupOpenerBridgeParent = nil
113+ wv.popupOpenerBridgeParentURI = ""
114+ return
115+ }
116+ parentWV, ok := parent.(*WebView)
117+ if !ok || parentWV == nil {
118+ wv.popupOpenerBridgeParent = nil
119+ wv.popupOpenerBridgeParentURI = ""
120+ return
121+ }
122+ wv.popupOpenerBridgeParent = parentWV
123+ wv.popupOpenerBridgeParentURI = parentWV.URI()
124+}
125+
126+func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string {
127+ if wv == nil || bridgeNonce == "" {
128+ return ""
129+ }
130+
131+ wv.mu.RLock()
132+ parentURI := wv.popupOpenerBridgeParentURI
133+ parent := wv.popupOpenerBridgeParent
134+ blocked := wv.popupNoJavaScriptAccess || parent == nil
135+ wv.mu.RUnlock()
136+ if blocked {
137+ return ""
138+ }
139+
140+ return fmt.Sprintf(`(function() {
141+ if (typeof window === 'undefined') return;
142+ if (window.__dumberPopupOpenerBridgeInstalled) return;
143+ window.__dumberPopupOpenerBridgeInstalled = true;
144+ if (window.opener != null) return;
145+
146+ var bridgeNonce = '%s';
147+ var openerHref = '%s';
148+
149+ function postBridge(path, payload) {
150+ try {
151+ fetch('dumb:///api/' + path, {
152+ method: 'POST',
153+ headers: {
154+ 'Content-Type': 'application/json',
155+ 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
156+ 'X-Dumber-Bridge-Nonce': bridgeNonce
157+ }
158+ }).catch(function() {});
159+ } catch (_) {}
160+ }
161+
162+ function normalizeURLValue(rawURL) {
163+ if (rawURL == null) return '';
164+ try {
165+ return String(rawURL);
166+ } catch (_) {
167+ return '';
168+ }
169+ }
170+
171+ function navigateOpener(nextURL) {
172+ var rawURL = normalizeURLValue(nextURL);
173+ if (rawURL !== '') openerHref = rawURL;
174+ postBridge('popup-opener-navigate', { url: rawURL });
175+ return openerHref;
176+ }
177+
178+ function serializeMessage(value) {
179+ try {
180+ return { kind: 'json', value: JSON.stringify(value) };
181+ } catch (_) {
182+ return { kind: 'string', value: String(value) };
183+ }
184+ }
185+
186+ var locationProxy = {
187+ assign: function(nextURL) { return navigateOpener(nextURL); },
188+ replace: function(nextURL) { return navigateOpener(nextURL); },
189+ toString: function() { return openerHref; }
190+ };
191+ try {
192+ Object.defineProperty(locationProxy, 'href', {
193+ configurable: true,
194+ enumerable: true,
195+ get: function() { return openerHref; },
196+ set: function(nextURL) { navigateOpener(nextURL); }
197+ });
198+ } catch (_) {
199+ locationProxy.href = openerHref;
200+ }
201+
202+ var openerProxy = {
203+ blur: function() { return undefined; },
204+ close: function() { return undefined; },
205+ focus: function() { return undefined; },
206+ postMessage: function(message, targetOrigin) {
207+ var serialized = serializeMessage(message);
208+ postBridge('popup-opener-post-message', {
209+ data: serialized.value,
210+ data_kind: serialized.kind,
211+ target_origin: targetOrigin == null ? '*' : String(targetOrigin),
212+ source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
213+ source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
214+ });
215+ return undefined;
216+ }
217+ };
218+ openerProxy.self = openerProxy;
219+ openerProxy.window = openerProxy;
220+ try {
221+ Object.defineProperty(openerProxy, 'closed', {
222+ configurable: true,
223+ enumerable: true,
224+ get: function() { return false; }
225+ });
226+ } catch (_) {
227+ openerProxy.closed = false;
228+ }
229+ try {
230+ Object.defineProperty(openerProxy, 'location', {
231+ configurable: true,
232+ enumerable: true,
233+ get: function() { return locationProxy; },
234+ set: function(nextURL) { navigateOpener(nextURL); }
235+ });
236+ } catch (_) {
237+ openerProxy.location = locationProxy;
238+ }
239+
240+ try {
241+ Object.defineProperty(window, 'opener', {
242+ configurable: true,
243+ enumerable: true,
244+ get: function() { return openerProxy; }
245+ });
246+ } catch (_) {
247+ try { window.opener = openerProxy; } catch (_) {}
248+ }
249+})();`, webutil.EscapeForJSString(bridgeNonce), webutil.EscapeForJSString(parentURI))
250+}
251+
252+func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {
253+ if wv == nil {
254+ return
255+ }
256+
257+ trimmedURL := strings.TrimSpace(targetURL)
258+ if trimmedURL == "" {
259+ return
260+ }
261+
262+ wv.mu.RLock()
263+ opener := wv.popupOpenerBridgeParent
264+ wv.mu.RUnlock()
265+ if opener == nil || opener.destroyed.Load() {
266+ return
267+ }
268+
269+ resolvedURL := resolvePopupOpenerNavigationTarget(trimmedURL, opener.URI())
270+ if resolvedURL == "" {
271+ return
272+ }
273+
274+ if err := opener.LoadURI(context.Background(), resolvedURL); err != nil && opener.ctx != nil {
275+ logging.FromContext(opener.ctx).Warn().
276+ Err(err).
277+ Str("uri", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)).
278+ Msg("cef: failed to navigate synthetic popup opener")
279+ }
280+
281+ wv.mu.Lock()
282+ wv.popupOpenerBridgeParentURI = resolvedURL
283+ wv.mu.Unlock()
284+ wv.runOpenerNavigationCallbacks(resolvedURL)
285+}
286+
287+func resolvePopupOpenerNavigationTarget(rawTarget, openerURI string) string {
288+ trimmedTarget := strings.TrimSpace(rawTarget)
289+ if trimmedTarget == "" {
290+ return ""
291+ }
292+ parsedTarget, err := neturl.Parse(trimmedTarget)
293+ if err == nil && parsedTarget.IsAbs() {
294+ return parsedTarget.String()
295+ }
296+ if strings.HasPrefix(trimmedTarget, "//") {
297+ base, baseErr := neturl.Parse(strings.TrimSpace(openerURI))
298+ if baseErr == nil && base != nil && base.Scheme != "" {
299+ parsedTarget.Scheme = base.Scheme
300+ return parsedTarget.String()
301+ }
302+ return ""
303+ }
304+ base, err := neturl.Parse(strings.TrimSpace(openerURI))
305+ if err != nil || base == nil || base.Scheme == "" || base.Host == "" {
306+ return ""
307+ }
308+ ref, err := neturl.Parse(trimmedTarget)
309+ if err != nil {
310+ return ""
311+ }
312+ return base.ResolveReference(ref).String()
313+}
314+
315+func (wv *WebView) handlePopupOpenerPostMessage(payload popupOpenerPostMessagePayload) {
316+ if wv == nil {
317+ return
318+ }
319+
320+ wv.mu.RLock()
321+ opener := wv.popupOpenerBridgeParent
322+ wv.mu.RUnlock()
323+ if opener == nil || opener.destroyed.Load() {
324+ return
325+ }
326+ if !targetOriginMatchesPopupOpener(payload.TargetOrigin, opener.URI()) {
327+ return
328+ }
329+
330+ sourceOrigin := popupSourceOrigin(payload)
331+ dataExpr := "undefined"
332+ switch strings.ToLower(strings.TrimSpace(payload.DataKind)) {
333+ case "json":
334+ dataExpr = fmt.Sprintf("JSON.parse('%s')", webutil.EscapeForJSString(payload.Data))
335+ case "string":
336+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
337+ default:
338+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
339+ }
340+
341+ sourceHref := strings.TrimSpace(payload.SourceHref)
342+ script := fmt.Sprintf(`(function() {
343+ try {
344+ var data;
345+ try {
346+ data = %s;
347+ } catch (_) {
348+ data = undefined;
349+ }
350+ var event = new MessageEvent('message', {
351+ data: data,
352+ origin: '%s',
353+ source: null
354+ });
355+ var sourceProxy = {
356+ closed: false,
357+ close: function() { return undefined; },
358+ focus: function() { return undefined; },
359+ postMessage: function() { return undefined; },
360+ location: {
361+ href: '%s',
362+ toString: function() { return this.href; }
363+ }
364+ };
365+ try {
366+ Object.defineProperty(event, 'source', {
367+ configurable: true,
368+ enumerable: true,
369+ value: sourceProxy
370+ });
371+ } catch (_) {}
372+ window.dispatchEvent(event);
373+ } catch (_) {}
374+})();`, dataExpr, webutil.EscapeForJSString(sourceOrigin), webutil.EscapeForJSString(sourceHref))
375+ opener.RunJavaScript(context.Background(), script)
376+ wv.runOpenerMessageCallbacks()
377+}
378+
379+func targetOriginMatchesPopupOpener(targetOrigin, openerURI string) bool {
380+ trimmedTarget := strings.TrimSpace(targetOrigin)
381+ if trimmedTarget == "" || trimmedTarget == "*" {
382+ return true
383+ }
384+ return canonicalOrigin(trimmedTarget) != "" && canonicalOrigin(trimmedTarget) == canonicalOrigin(openerURI)
385+}
386+
387+func popupSourceOrigin(payload popupOpenerPostMessagePayload) string {
388+ if origin := strings.TrimSpace(payload.SourceOrigin); origin != "" {
389+ return origin
390+ }
391+ return originFromURL(payload.SourceHref)
392+}
393+
394+func originFromURL(rawURL string) string {
395+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
396+ parsed, err := neturl.Parse(canonicalURL)
397+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
398+ return ""
399+ }
400+ return parsed.Scheme + "://" + parsed.Host
401+}
402+
403+func canonicalOrigin(rawURL string) string {
404+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
405+ parsed, err := neturl.Parse(canonicalURL)
406+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
407+ return ""
408+ }
409+ scheme := strings.ToLower(parsed.Scheme)
410+ host := strings.ToLower(parsed.Hostname())
411+ if host == "" {
412+ return ""
413+ }
414+ port := parsed.Port()
415+ if port == "" {
416+ switch scheme {
417+ case "http":
418+ port = "80"
419+ case "https":
420+ port = "443"
421+ }
422+ }
423+ if port == "" {
424+ return scheme + "://" + host
425+ }
426+ return scheme + "://" + host + ":" + port
427+}
@@ -0,0 +1,427 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ neturl "net/url"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/infrastructure/webutil"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+type popupOpenerPostMessagePayload struct {
15+ Data string `json:"data"`
16+ DataKind string `json:"data_kind"`
17+ TargetOrigin string `json:"target_origin"`
18+ SourceOrigin string `json:"source_origin"`
19+ SourceHref string `json:"source_href"`
20+}
21+
22+type popupOpenerNavigatePayload struct {
23+ URL string `json:"url"`
24+}
25+
26+func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) {
27+ if wv == nil {
28+ return
29+ }
30+ wv.mu.Lock()
31+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
32+ wv.mu.Unlock()
33+}
34+
35+// AddOpenerMessageCallback implements port.PopupOpenerMessageCapable.
36+func (wv *WebView) AddOpenerMessageCallback(fn func()) {
37+ if wv == nil || fn == nil {
38+ return
39+ }
40+ wv.mu.Lock()
41+ wv.openerMessageCallbacks = append(wv.openerMessageCallbacks, fn)
42+ wv.mu.Unlock()
43+}
44+
45+// AddOpenerNavigationCallback implements port.PopupOpenerNavigationCapable.
46+func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {
47+ if wv == nil || fn == nil {
48+ return
49+ }
50+ wv.mu.Lock()
51+ wv.openerNavigationCallbacks = append(wv.openerNavigationCallbacks, fn)
52+ wv.mu.Unlock()
53+}
54+
55+func (wv *WebView) runOpenerMessageCallbacks() {
56+ if wv == nil {
57+ return
58+ }
59+ wv.mu.RLock()
60+ callbacks := append([]func(){}, wv.openerMessageCallbacks...)
61+ wv.mu.RUnlock()
62+ if len(callbacks) == 0 {
63+ return
64+ }
65+ wv.runOnGTK(func() {
66+ for _, fn := range callbacks {
67+ if fn != nil {
68+ fn()
69+ }
70+ }
71+ })
72+}
73+
74+func (wv *WebView) runOpenerNavigationCallbacks(uri string) {
75+ if wv == nil {
76+ return
77+ }
78+ wv.mu.RLock()
79+ callbacks := append([]func(string){}, wv.openerNavigationCallbacks...)
80+ wv.mu.RUnlock()
81+ if len(callbacks) == 0 {
82+ return
83+ }
84+ wv.runOnGTK(func() {
85+ for _, fn := range callbacks {
86+ if fn != nil {
87+ fn(uri)
88+ }
89+ }
90+ })
91+}
92+
93+// HasActivePopupOpenerBridge implements port.PopupOpenerBridgeStateCapable.
94+func (wv *WebView) HasActivePopupOpenerBridge() bool {
95+ if wv == nil {
96+ return false
97+ }
98+ wv.mu.RLock()
99+ defer wv.mu.RUnlock()
100+ return !wv.popupNoJavaScriptAccess && wv.popupOpenerBridgeParent != nil
101+}
102+
103+// EnablePopupOpenerBridge implements port.PopupOpenerBridgeCapable.
104+func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
105+ if wv == nil {
106+ return
107+ }
108+ wv.mu.Lock()
109+ defer wv.mu.Unlock()
110+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
111+ if noJavaScriptAccess {
112+ wv.popupOpenerBridgeParent = nil
113+ wv.popupOpenerBridgeParentURI = ""
114+ return
115+ }
116+ parentWV, ok := parent.(*WebView)
117+ if !ok || parentWV == nil {
118+ wv.popupOpenerBridgeParent = nil
119+ wv.popupOpenerBridgeParentURI = ""
120+ return
121+ }
122+ wv.popupOpenerBridgeParent = parentWV
123+ wv.popupOpenerBridgeParentURI = parentWV.URI()
124+}
125+
126+func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string {
127+ if wv == nil || bridgeNonce == "" {
128+ return ""
129+ }
130+
131+ wv.mu.RLock()
132+ parentURI := wv.popupOpenerBridgeParentURI
133+ parent := wv.popupOpenerBridgeParent
134+ blocked := wv.popupNoJavaScriptAccess || parent == nil
135+ wv.mu.RUnlock()
136+ if blocked {
137+ return ""
138+ }
139+
140+ return fmt.Sprintf(`(function() {
141+ if (typeof window === 'undefined') return;
142+ if (window.__dumberPopupOpenerBridgeInstalled) return;
143+ window.__dumberPopupOpenerBridgeInstalled = true;
144+ if (window.opener != null) return;
145+
146+ var bridgeNonce = '%s';
147+ var openerHref = '%s';
148+
149+ function postBridge(path, payload) {
150+ try {
151+ fetch('dumb:///api/' + path, {
152+ method: 'POST',
153+ headers: {
154+ 'Content-Type': 'application/json',
155+ 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
156+ 'X-Dumber-Bridge-Nonce': bridgeNonce
157+ }
158+ }).catch(function() {});
159+ } catch (_) {}
160+ }
161+
162+ function normalizeURLValue(rawURL) {
163+ if (rawURL == null) return '';
164+ try {
165+ return String(rawURL);
166+ } catch (_) {
167+ return '';
168+ }
169+ }
170+
171+ function navigateOpener(nextURL) {
172+ var rawURL = normalizeURLValue(nextURL);
173+ if (rawURL !== '') openerHref = rawURL;
174+ postBridge('popup-opener-navigate', { url: rawURL });
175+ return openerHref;
176+ }
177+
178+ function serializeMessage(value) {
179+ try {
180+ return { kind: 'json', value: JSON.stringify(value) };
181+ } catch (_) {
182+ return { kind: 'string', value: String(value) };
183+ }
184+ }
185+
186+ var locationProxy = {
187+ assign: function(nextURL) { return navigateOpener(nextURL); },
188+ replace: function(nextURL) { return navigateOpener(nextURL); },
189+ toString: function() { return openerHref; }
190+ };
191+ try {
192+ Object.defineProperty(locationProxy, 'href', {
193+ configurable: true,
194+ enumerable: true,
195+ get: function() { return openerHref; },
196+ set: function(nextURL) { navigateOpener(nextURL); }
197+ });
198+ } catch (_) {
199+ locationProxy.href = openerHref;
200+ }
201+
202+ var openerProxy = {
203+ blur: function() { return undefined; },
204+ close: function() { return undefined; },
205+ focus: function() { return undefined; },
206+ postMessage: function(message, targetOrigin) {
207+ var serialized = serializeMessage(message);
208+ postBridge('popup-opener-post-message', {
209+ data: serialized.value,
210+ data_kind: serialized.kind,
211+ target_origin: targetOrigin == null ? '*' : String(targetOrigin),
212+ source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
213+ source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
214+ });
215+ return undefined;
216+ }
217+ };
218+ openerProxy.self = openerProxy;
219+ openerProxy.window = openerProxy;
220+ try {
221+ Object.defineProperty(openerProxy, 'closed', {
222+ configurable: true,
223+ enumerable: true,
224+ get: function() { return false; }
225+ });
226+ } catch (_) {
227+ openerProxy.closed = false;
228+ }
229+ try {
230+ Object.defineProperty(openerProxy, 'location', {
231+ configurable: true,
232+ enumerable: true,
233+ get: function() { return locationProxy; },
234+ set: function(nextURL) { navigateOpener(nextURL); }
235+ });
236+ } catch (_) {
237+ openerProxy.location = locationProxy;
238+ }
239+
240+ try {
241+ Object.defineProperty(window, 'opener', {
242+ configurable: true,
243+ enumerable: true,
244+ get: function() { return openerProxy; }
245+ });
246+ } catch (_) {
247+ try { window.opener = openerProxy; } catch (_) {}
248+ }
249+})();`, webutil.EscapeForJSString(bridgeNonce), webutil.EscapeForJSString(parentURI))
250+}
251+
252+func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {
253+ if wv == nil {
254+ return
255+ }
256+
257+ trimmedURL := strings.TrimSpace(targetURL)
258+ if trimmedURL == "" {
259+ return
260+ }
261+
262+ wv.mu.RLock()
263+ opener := wv.popupOpenerBridgeParent
264+ wv.mu.RUnlock()
265+ if opener == nil || opener.destroyed.Load() {
266+ return
267+ }
268+
269+ resolvedURL := resolvePopupOpenerNavigationTarget(trimmedURL, opener.URI())
270+ if resolvedURL == "" {
271+ return
272+ }
273+
274+ if err := opener.LoadURI(context.Background(), resolvedURL); err != nil && opener.ctx != nil {
275+ logging.FromContext(opener.ctx).Warn().
276+ Err(err).
277+ Str("uri", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)).
278+ Msg("cef: failed to navigate synthetic popup opener")
279+ }
280+
281+ wv.mu.Lock()
282+ wv.popupOpenerBridgeParentURI = resolvedURL
283+ wv.mu.Unlock()
284+ wv.runOpenerNavigationCallbacks(resolvedURL)
285+}
286+
287+func resolvePopupOpenerNavigationTarget(rawTarget, openerURI string) string {
288+ trimmedTarget := strings.TrimSpace(rawTarget)
289+ if trimmedTarget == "" {
290+ return ""
291+ }
292+ parsedTarget, err := neturl.Parse(trimmedTarget)
293+ if err == nil && parsedTarget.IsAbs() {
294+ return parsedTarget.String()
295+ }
296+ if strings.HasPrefix(trimmedTarget, "//") {
297+ base, baseErr := neturl.Parse(strings.TrimSpace(openerURI))
298+ if baseErr == nil && base != nil && base.Scheme != "" {
299+ parsedTarget.Scheme = base.Scheme
300+ return parsedTarget.String()
301+ }
302+ return ""
303+ }
304+ base, err := neturl.Parse(strings.TrimSpace(openerURI))
305+ if err != nil || base == nil || base.Scheme == "" || base.Host == "" {
306+ return ""
307+ }
308+ ref, err := neturl.Parse(trimmedTarget)
309+ if err != nil {
310+ return ""
311+ }
312+ return base.ResolveReference(ref).String()
313+}
314+
315+func (wv *WebView) handlePopupOpenerPostMessage(payload popupOpenerPostMessagePayload) {
316+ if wv == nil {
317+ return
318+ }
319+
320+ wv.mu.RLock()
321+ opener := wv.popupOpenerBridgeParent
322+ wv.mu.RUnlock()
323+ if opener == nil || opener.destroyed.Load() {
324+ return
325+ }
326+ if !targetOriginMatchesPopupOpener(payload.TargetOrigin, opener.URI()) {
327+ return
328+ }
329+
330+ sourceOrigin := popupSourceOrigin(payload)
331+ dataExpr := "undefined"
332+ switch strings.ToLower(strings.TrimSpace(payload.DataKind)) {
333+ case "json":
334+ dataExpr = fmt.Sprintf("JSON.parse('%s')", webutil.EscapeForJSString(payload.Data))
335+ case "string":
336+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
337+ default:
338+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
339+ }
340+
341+ sourceHref := strings.TrimSpace(payload.SourceHref)
342+ script := fmt.Sprintf(`(function() {
343+ try {
344+ var data;
345+ try {
346+ data = %s;
347+ } catch (_) {
348+ data = undefined;
349+ }
350+ var event = new MessageEvent('message', {
351+ data: data,
352+ origin: '%s',
353+ source: null
354+ });
355+ var sourceProxy = {
356+ closed: false,
357+ close: function() { return undefined; },
358+ focus: function() { return undefined; },
359+ postMessage: function() { return undefined; },
360+ location: {
361+ href: '%s',
362+ toString: function() { return this.href; }
363+ }
364+ };
365+ try {
366+ Object.defineProperty(event, 'source', {
367+ configurable: true,
368+ enumerable: true,
369+ value: sourceProxy
370+ });
371+ } catch (_) {}
372+ window.dispatchEvent(event);
373+ } catch (_) {}
374+})();`, dataExpr, webutil.EscapeForJSString(sourceOrigin), webutil.EscapeForJSString(sourceHref))
375+ opener.RunJavaScript(context.Background(), script)
376+ wv.runOpenerMessageCallbacks()
377+}
378+
379+func targetOriginMatchesPopupOpener(targetOrigin, openerURI string) bool {
380+ trimmedTarget := strings.TrimSpace(targetOrigin)
381+ if trimmedTarget == "" || trimmedTarget == "*" {
382+ return true
383+ }
384+ return canonicalOrigin(trimmedTarget) != "" && canonicalOrigin(trimmedTarget) == canonicalOrigin(openerURI)
385+}
386+
387+func popupSourceOrigin(payload popupOpenerPostMessagePayload) string {
388+ if origin := strings.TrimSpace(payload.SourceOrigin); origin != "" {
389+ return origin
390+ }
391+ return originFromURL(payload.SourceHref)
392+}
393+
394+func originFromURL(rawURL string) string {
395+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
396+ parsed, err := neturl.Parse(canonicalURL)
397+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
398+ return ""
399+ }
400+ return parsed.Scheme + "://" + parsed.Host
401+}
402+
403+func canonicalOrigin(rawURL string) string {
404+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
405+ parsed, err := neturl.Parse(canonicalURL)
406+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
407+ return ""
408+ }
409+ scheme := strings.ToLower(parsed.Scheme)
410+ host := strings.ToLower(parsed.Hostname())
411+ if host == "" {
412+ return ""
413+ }
414+ port := parsed.Port()
415+ if port == "" {
416+ switch scheme {
417+ case "http":
418+ port = "80"
419+ case "https":
420+ port = "443"
421+ }
422+ }
423+ if port == "" {
424+ return scheme + "://" + host
425+ }
426+ return scheme + "://" + host + ":" + port
427+}
A internal/infrastructure/cef/popup_opener_bridge_test.go +33 -0
@@ -0,0 +1,33 @@
1+package cef
2+
3+import "testing"
4+
5+import "github.com/stretchr/testify/require"
6+
7+func TestResolvePopupOpenerNavigationTarget_ResolvesRelativeAgainstOpener(t *testing.T) {
8+ require.Equal(
9+ t,
10+ "https://example.com/oauth/callback",
11+ resolvePopupOpenerNavigationTarget("callback", "https://example.com/oauth/start"),
12+ )
13+}
14+
15+func TestResolvePopupOpenerNavigationTarget_PreservesAbsoluteTarget(t *testing.T) {
16+ require.Equal(
17+ t,
18+ "https://accounts.example.com/finish",
19+ resolvePopupOpenerNavigationTarget("https://accounts.example.com/finish", "https://example.com/oauth/start"),
20+ )
21+}
22+
23+func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
24+ require.Equal(t, "https://example.com:8443", originFromURL("https://example.com:8443/callback"))
25+}
26+
27+func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
28+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))
29+}
30+
31+func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
32+ require.True(t, targetOriginMatchesPopupOpener("https://example.com:443", "https://example.com/callback"))
33+}
@@ -0,0 +1,33 @@
1+package cef
2+
3+import "testing"
4+
5+import "github.com/stretchr/testify/require"
6+
7+func TestResolvePopupOpenerNavigationTarget_ResolvesRelativeAgainstOpener(t *testing.T) {
8+ require.Equal(
9+ t,
10+ "https://example.com/oauth/callback",
11+ resolvePopupOpenerNavigationTarget("callback", "https://example.com/oauth/start"),
12+ )
13+}
14+
15+func TestResolvePopupOpenerNavigationTarget_PreservesAbsoluteTarget(t *testing.T) {
16+ require.Equal(
17+ t,
18+ "https://accounts.example.com/finish",
19+ resolvePopupOpenerNavigationTarget("https://accounts.example.com/finish", "https://example.com/oauth/start"),
20+ )
21+}
22+
23+func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
24+ require.Equal(t, "https://example.com:8443", originFromURL("https://example.com:8443/callback"))
25+}
26+
27+func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
28+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))
29+}
30+
31+func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
32+ require.True(t, targetOriginMatchesPopupOpener("https://example.com:443", "https://example.com/callback"))
33+}
M internal/infrastructure/cef/runtime_config.go +2 -1
@@ -5,11 +5,12 @@ import (
55 "errors"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
89 )
910
1011 var (
1112 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12- ErrRelatedWebViewUnsupported = port.ErrRelatedWebViewUnsupported
13+ ErrRelatedWebViewUnsupported = domainerrors.ErrRelatedWebViewUnsupported
1314 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
1415 )
1516
@@ -5,11 +5,12 @@ import (
5 "errors"5 "errors"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
8 )9 )
9 10
10 var (11 var (
11 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")12 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12- ErrRelatedWebViewUnsupported = port.ErrRelatedWebViewUnsupported13+ ErrRelatedWebViewUnsupported = domainerrors.ErrRelatedWebViewUnsupported
13 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")14 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
14 )15 )
15 16
M internal/infrastructure/cef/scheme_handler.go +41 -3
@@ -69,9 +69,11 @@ type dumbSchemeHandler struct {
6969 // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
7070 // proxies from page JavaScript back into the browser process when native
7171 // related popups are not 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- onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
72+ onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
73+ onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
74+ onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
75+ onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
76+ onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
7577
7678 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
7779 // browser/navigation context that issued the request.
@@ -212,6 +214,10 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
212214 return h.handlePopupNavigate(request, browser), true
213215 case "/api/popup-close":
214216 return h.handlePopupClose(request, browser), true
217+ case "/api/popup-opener-navigate":
218+ return h.handlePopupOpenerNavigate(request, browser), true
219+ case "/api/popup-opener-post-message":
220+ return h.handlePopupOpenerPostMessage(request, browser), true
215221 default:
216222 return nil, false
217223 }
@@ -415,6 +421,38 @@ func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser pu
415421 return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
416422 }
417423
424+func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) {
425+ var payload popupOpenerNavigatePayload
426+ if err := json.Unmarshal(body, &payload); err != nil {
427+ return payload, err
428+ }
429+ payload.URL = strings.TrimSpace(payload.URL)
430+ if payload.URL == "" {
431+ return payload, fmt.Errorf("missing url")
432+ }
433+ return payload, nil
434+}
435+
436+func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) {
437+ var payload popupOpenerPostMessagePayload
438+ if err := json.Unmarshal(body, &payload); err != nil {
439+ return payload, err
440+ }
441+ payload.DataKind = strings.TrimSpace(payload.DataKind)
442+ payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin)
443+ payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin)
444+ payload.SourceHref = strings.TrimSpace(payload.SourceHref)
445+ return payload, nil
446+}
447+
448+func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
449+ return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
450+}
451+
452+func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
453+ return handlePopupBridgeRequest(h, request, browser, "popup-opener-post-message", decodePopupOpenerPostMessagePayload, h.onPopupOpenerPostMessage)
454+}
455+
418456 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
419457 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
420458 return false
@@ -69,9 +69,11 @@ type dumbSchemeHandler struct {
69 // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()69 // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
70 // proxies from page JavaScript back into the browser process when native70 // 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).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+ onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
75+ onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
76+ onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
75 77
76 // bridgeNonceValidator checks whether a bridge nonce belongs to the active78 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
77 // browser/navigation context that issued the request.79 // browser/navigation context that issued the request.
@@ -212,6 +214,10 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
212 return h.handlePopupNavigate(request, browser), true214 return h.handlePopupNavigate(request, browser), true
213 case "/api/popup-close":215 case "/api/popup-close":
214 return h.handlePopupClose(request, browser), true216 return h.handlePopupClose(request, browser), true
217+ case "/api/popup-opener-navigate":
218+ return h.handlePopupOpenerNavigate(request, browser), true
219+ case "/api/popup-opener-post-message":
220+ return h.handlePopupOpenerPostMessage(request, browser), true
215 default:221 default:
216 return nil, false222 return nil, false
217 }223 }
@@ -415,6 +421,38 @@ func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser pu
415 return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)421 return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
416 }422 }
417 423
424+func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) {
425+ var payload popupOpenerNavigatePayload
426+ if err := json.Unmarshal(body, &payload); err != nil {
427+ return payload, err
428+ }
429+ payload.URL = strings.TrimSpace(payload.URL)
430+ if payload.URL == "" {
431+ return payload, fmt.Errorf("missing url")
432+ }
433+ return payload, nil
434+}
435+
436+func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) {
437+ var payload popupOpenerPostMessagePayload
438+ if err := json.Unmarshal(body, &payload); err != nil {
439+ return payload, err
440+ }
441+ payload.DataKind = strings.TrimSpace(payload.DataKind)
442+ payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin)
443+ payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin)
444+ payload.SourceHref = strings.TrimSpace(payload.SourceHref)
445+ return payload, nil
446+}
447+
448+func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
449+ return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
450+}
451+
452+func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
453+ return handlePopupBridgeRequest(h, request, browser, "popup-opener-post-message", decodePopupOpenerPostMessagePayload, h.onPopupOpenerPostMessage)
454+}
455+
418 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {456 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
419 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {457 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
420 return false458 return false
M internal/infrastructure/cef/webview.go +398 -8
@@ -23,10 +23,16 @@ import (
2323
2424 // Compile-time interface checks.
2525 var (
26- _ port.WebView = (*WebView)(nil)
27- _ port.NativeWidgetProvider = (*WebView)(nil)
28- _ port.DevToolsOpener = (*WebView)(nil)
29- _ port.OAuthCallbackCapable = (*WebView)(nil)
26+ _ port.WebView = (*WebView)(nil)
27+ _ port.NativeWidgetProvider = (*WebView)(nil)
28+ _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.PopupCapable = (*WebView)(nil)
30+ _ port.PopupNavigationCapable = (*WebView)(nil)
31+ _ port.PopupOpenerBridgeCapable = (*WebView)(nil)
32+ _ port.PopupOpenerMessageCapable = (*WebView)(nil)
33+ _ port.PopupOpenerNavigationCapable = (*WebView)(nil)
34+ _ port.PopupOpenerBridgeStateCapable = (*WebView)(nil)
35+ _ port.OAuthCallbackCapable = (*WebView)(nil)
3036 )
3137
3238 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -39,6 +45,7 @@ const (
3945 clipboardSelectionDebounceDelay = 300 * time.Millisecond
4046 pendingNavigationRetryDelay = 50 * time.Millisecond
4147 pendingNavigationMaxRetries = 80
48+ nativePopupAttachFallbackDelay = 250 * time.Millisecond
4249 )
4350
4451 type stoppableTimer interface {
@@ -101,8 +108,28 @@ type WebView struct {
101108
102109 // Programmatic popup lifecycle callbacks used for OAuth auto-close and
103110 // synthetic window.open() proxy support on CEF.
104- closeCallbacks []func()
105- navigationCallbacks []func(string)
111+ closeCallbacks []func()
112+ navigationCallbacks []func(string)
113+ openerMessageCallbacks []func()
114+ openerNavigationCallbacks []func(string)
115+ popupReadyToShow func()
116+ popupReadyShown bool
117+
118+ // Native popup bookkeeping. CreateRelated() returns a popup shell with no
119+ // browser attached yet; OnBeforePopup may wire CEF's real popup browser into
120+ // that shell. If native attachment is unavailable or stalls, the shell can
121+ // still create its own browser directly while preserving the same popup pane
122+ // lifecycle from the coordinator's perspective.
123+ nativePopupCandidate bool
124+ nativePopupParent *WebView
125+ nativePopupID int32
126+ nativePopupFallbackStarted bool
127+ nativePopupFallbackTimer stoppableTimer
128+ nativePopupFallbackSchedule func(time.Duration, func()) stoppableTimer
129+ pendingNativePopups map[int32]*WebView
130+ popupNoJavaScriptAccess bool
131+ popupOpenerBridgeParent *WebView
132+ popupOpenerBridgeParentURI string
106133
107134 // State cache (mutex-protected).
108135 uri string
@@ -136,6 +163,11 @@ type WebView struct {
136163 audioPlaying atomic.Bool
137164 zoomFactor atomic.Value // float64, initialized to 1.0
138165
166+ // Browser creation defaults copied from the factory so native popup shells
167+ // can apply the same settings in OnBeforePopup.
168+ windowlessFrameRate int32
169+ backgroundColor uint32
170+
139171 // Audio output factory and active stream.
140172 audioOutputFactory port.AudioOutputFactory
141173 audioStreamMu sync.Mutex
@@ -475,6 +507,56 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
475507 wv.callbacks = cb
476508 }
477509
510+// SetOnReadyToShow implements port.PopupCapable.
511+func (wv *WebView) SetOnReadyToShow(fn func()) {
512+ if wv == nil {
513+ return
514+ }
515+ wv.mu.Lock()
516+ wv.popupReadyToShow = fn
517+ alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil
518+ wv.mu.Unlock()
519+ if alreadyReady {
520+ wv.fireReadyToShow()
521+ }
522+}
523+
524+// SetOnClose implements port.PopupCapable.
525+func (wv *WebView) SetOnClose(fn func()) {
526+ wv.AddCloseCallback(fn)
527+}
528+
529+// Show implements port.PopupCapable.
530+func (wv *WebView) Show() {
531+ if wv == nil || wv.destroyed.Load() {
532+ return
533+ }
534+ wv.mu.RLock()
535+ host := wv.host
536+ wv.mu.RUnlock()
537+ if host != nil {
538+ host.WasHidden(0)
539+ }
540+}
541+
542+// PrimePopupNavigation implements port.PopupNavigationCapable.
543+func (wv *WebView) PrimePopupNavigation(uri string) {
544+ if wv == nil || wv.destroyed.Load() {
545+ return
546+ }
547+ actualURI := toActualInternalURL(strings.TrimSpace(uri))
548+ if actualURI == "" {
549+ return
550+ }
551+ wv.mu.Lock()
552+ browser := wv.browser
553+ wv.pendingURI = actualURI
554+ wv.mu.Unlock()
555+ if browser != nil {
556+ wv.schedulePendingNavigationReplay(0)
557+ }
558+}
559+
478560 // AddCloseCallback implements port.OAuthCallbackCapable.
479561 func (wv *WebView) AddCloseCallback(fn func()) {
480562 if wv == nil || fn == nil {
@@ -510,6 +592,276 @@ func (wv *WebView) Close() {
510592 wv.runCloseCallbacks()
511593 }
512594
595+func (wv *WebView) fireReadyToShow() {
596+ if wv == nil {
597+ return
598+ }
599+ wv.mu.Lock()
600+ if wv.popupReadyShown || wv.popupReadyToShow == nil {
601+ wv.mu.Unlock()
602+ return
603+ }
604+ fn := wv.popupReadyToShow
605+ wv.popupReadyShown = true
606+ wv.mu.Unlock()
607+ wv.runOnGTK(fn)
608+}
609+
610+func (wv *WebView) markNativePopupCandidate(parent *WebView) {
611+ if wv == nil {
612+ return
613+ }
614+ wv.stopNativePopupFallbackTimer()
615+ wv.mu.Lock()
616+ wv.nativePopupCandidate = true
617+ wv.nativePopupParent = parent
618+ wv.nativePopupID = 0
619+ wv.nativePopupFallbackStarted = false
620+ wv.mu.Unlock()
621+}
622+
623+func (wv *WebView) isNativePopupCandidate() bool {
624+ if wv == nil {
625+ return false
626+ }
627+ wv.mu.RLock()
628+ defer wv.mu.RUnlock()
629+ return wv.nativePopupCandidate
630+}
631+
632+func popupParentWindowHandle(parent *WebView) uintptr {
633+ if parent == nil {
634+ return 0
635+ }
636+ parent.mu.RLock()
637+ host := parent.host
638+ parent.mu.RUnlock()
639+ if host == nil {
640+ return 0
641+ }
642+ if handle := host.GetWindowHandle(); handle != 0 {
643+ return handle
644+ }
645+ return host.GetOpenerWindowHandle()
646+}
647+
648+func (wv *WebView) discardNativePopupCandidate() {
649+ if wv == nil {
650+ return
651+ }
652+ wv.mu.Lock()
653+ wv.nativePopupCandidate = false
654+ wv.nativePopupParent = nil
655+ wv.nativePopupID = 0
656+ wv.mu.Unlock()
657+}
658+
659+func (wv *WebView) awaitsNativePopupAttachment() bool {
660+ if wv == nil {
661+ return false
662+ }
663+ wv.mu.RLock()
664+ defer wv.mu.RUnlock()
665+ return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
666+}
667+
668+func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
669+ if wv != nil && wv.nativePopupFallbackSchedule != nil {
670+ return wv.nativePopupFallbackSchedule
671+ }
672+ return func(delay time.Duration, fn func()) stoppableTimer {
673+ return time.AfterFunc(delay, fn)
674+ }
675+}
676+
677+func (wv *WebView) stopNativePopupFallbackTimer() {
678+ if wv == nil {
679+ return
680+ }
681+ wv.mu.Lock()
682+ timer := wv.nativePopupFallbackTimer
683+ wv.nativePopupFallbackTimer = nil
684+ wv.mu.Unlock()
685+ if timer != nil {
686+ timer.Stop()
687+ }
688+}
689+
690+func (wv *WebView) scheduleNativePopupFallback(delay time.Duration, fn func()) {
691+ if wv == nil || fn == nil {
692+ return
693+ }
694+ wv.stopNativePopupFallbackTimer()
695+ var timer stoppableTimer
696+ timer = wv.nativePopupFallbackScheduler()(delay, func() {
697+ wv.mu.Lock()
698+ if wv.nativePopupFallbackTimer != timer {
699+ wv.mu.Unlock()
700+ return
701+ }
702+ wv.nativePopupFallbackTimer = nil
703+ wv.mu.Unlock()
704+ fn()
705+ })
706+ wv.mu.Lock()
707+ wv.nativePopupFallbackTimer = timer
708+ wv.mu.Unlock()
709+}
710+
711+func (wv *WebView) preparePopupShellDirectBrowserCreation() bool {
712+ if wv == nil {
713+ return false
714+ }
715+ wv.stopNativePopupFallbackTimer()
716+ wv.mu.Lock()
717+ if wv.browser != nil || wv.nativePopupFallbackStarted || wv.pendingCreate == nil {
718+ wv.mu.Unlock()
719+ return false
720+ }
721+ parent := wv.nativePopupParent
722+ popupID := wv.nativePopupID
723+ wv.nativePopupFallbackStarted = true
724+ wv.nativePopupCandidate = false
725+ wv.nativePopupParent = nil
726+ wv.nativePopupID = 0
727+ if !wv.popupNoJavaScriptAccess && parent != nil {
728+ wv.popupOpenerBridgeParent = parent
729+ wv.popupOpenerBridgeParentURI = parent.URI()
730+ } else {
731+ wv.popupOpenerBridgeParent = nil
732+ wv.popupOpenerBridgeParentURI = ""
733+ }
734+ wv.mu.Unlock()
735+ if parent != nil && popupID != 0 {
736+ parent.clearPendingNativePopup(popupID, wv)
737+ }
738+ return true
739+}
740+
741+func (wv *WebView) startNativePopupFallback() bool {
742+ if wv == nil {
743+ return false
744+ }
745+ wv.mu.RLock()
746+ alreadyStarted := wv.nativePopupFallbackStarted
747+ wv.mu.RUnlock()
748+ if alreadyStarted {
749+ return false
750+ }
751+ return wv.preparePopupShellDirectBrowserCreation()
752+}
753+
754+func (wv *WebView) trackPendingNativePopup(popupID int32, popup *WebView) {
755+ if wv == nil || popupID == 0 || popup == nil {
756+ return
757+ }
758+ wv.mu.Lock()
759+ if wv.pendingNativePopups == nil {
760+ wv.pendingNativePopups = make(map[int32]*WebView)
761+ }
762+ wv.pendingNativePopups[popupID] = popup
763+ wv.mu.Unlock()
764+}
765+
766+func (wv *WebView) takePendingNativePopup(popupID int32) *WebView {
767+ if wv == nil || popupID == 0 {
768+ return nil
769+ }
770+ wv.mu.Lock()
771+ defer wv.mu.Unlock()
772+ popup := wv.pendingNativePopups[popupID]
773+ delete(wv.pendingNativePopups, popupID)
774+ return popup
775+}
776+
777+func (wv *WebView) clearPendingNativePopup(popupID int32, popup *WebView) {
778+ if wv == nil || popupID == 0 || popup == nil {
779+ return
780+ }
781+ wv.mu.Lock()
782+ defer wv.mu.Unlock()
783+ if current := wv.pendingNativePopups[popupID]; current == popup {
784+ delete(wv.pendingNativePopups, popupID)
785+ }
786+}
787+
788+func (wv *WebView) prepareNativePopup(
789+ popupID int32,
790+ targetURL string,
791+ windowInfo *purecef.WindowInfo,
792+ clientSlot *purecef.RawClientWriteSlot,
793+ settings *purecef.BrowserSettings,
794+) bool {
795+ if wv == nil || wv.destroyed.Load() || popupID == 0 || windowInfo == nil || clientSlot == nil {
796+ return false
797+ }
798+
799+ wv.mu.RLock()
800+ if !wv.nativePopupCandidate || wv.client == nil || wv.nativePopupFallbackStarted || wv.browser != nil {
801+ wv.mu.RUnlock()
802+ return false
803+ }
804+ parent := wv.nativePopupParent
805+ frameRate := wv.windowlessFrameRate
806+ backgroundColor := wv.backgroundColor
807+ wv.mu.RUnlock()
808+
809+ parentWindowHandle := popupParentWindowHandle(parent)
810+ if parentWindowHandle == 0 {
811+ return false
812+ }
813+
814+ wv.mu.Lock()
815+ if !wv.nativePopupCandidate || wv.client == nil || wv.nativePopupFallbackStarted || wv.browser != nil {
816+ wv.mu.Unlock()
817+ return false
818+ }
819+ wv.nativePopupCandidate = false
820+ wv.nativePopupID = popupID
821+ wv.nativePopupFallbackStarted = false
822+ if strings.TrimSpace(wv.pendingURI) == "" {
823+ wv.pendingURI = toActualInternalURL(targetURL)
824+ }
825+ if strings.TrimSpace(wv.pendingURI) != "" {
826+ wv.uri = toConceptualInternalURL(wv.pendingURI)
827+ } else {
828+ wv.uri = toConceptualInternalURL(targetURL)
829+ }
830+ wv.isLoading = true
831+ client := wv.client
832+ wv.mu.Unlock()
833+
834+ if parent != nil {
835+ parent.trackPendingNativePopup(popupID, wv)
836+ }
837+
838+ purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false)
839+ if externalBeginFrameEnabled() {
840+ windowInfo.ExternalBeginFrameEnabled = 1
841+ }
842+ if settings != nil {
843+ if frameRate > 0 {
844+ settings.WindowlessFrameRate = frameRate
845+ }
846+ settings.LocalStorage = 1
847+ if backgroundColor != 0 {
848+ settings.BackgroundColor = backgroundColor
849+ }
850+ }
851+ clientSlot.Set(client)
852+ return true
853+}
854+
855+func (wv *WebView) handleNativePopupAborted() {
856+ if wv == nil {
857+ return
858+ }
859+ wv.mu.Lock()
860+ wv.nativePopupCandidate = false
861+ wv.nativePopupID = 0
862+ wv.mu.Unlock()
863+}
864+
513865 // ---------------------------------------------------------------------------
514866 // JavaScript / Appearance
515867 // ---------------------------------------------------------------------------
@@ -554,6 +906,19 @@ func (wv *WebView) rotateBridgeNonce() string {
554906 return nonce
555907 }
556908
909+func (wv *WebView) ensureBridgeNonce() string {
910+ if wv == nil {
911+ return ""
912+ }
913+ wv.mu.RLock()
914+ nonce := wv.bridgeNonce
915+ wv.mu.RUnlock()
916+ if nonce != "" {
917+ return nonce
918+ }
919+ return wv.rotateBridgeNonce()
920+}
921+
557922 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
558923 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
559924 script := fmt.Sprintf(
@@ -587,7 +952,14 @@ func (wv *WebView) Destroy() {
587952 wv.mu.Lock()
588953 wv.closeCallbacks = nil
589954 wv.navigationCallbacks = nil
955+ wv.popupReadyToShow = nil
956+ wv.pendingNativePopups = nil
957+ wv.nativePopupParent = nil
958+ wv.nativePopupCandidate = false
959+ wv.nativePopupID = 0
960+ wv.nativePopupFallbackStarted = false
590961 wv.mu.Unlock()
962+ wv.stopNativePopupFallbackTimer()
591963 wv.cancelSelectionDebounce()
592964 if wv.resizeReconciler != nil {
593965 wv.resizeReconciler.stop()
@@ -1172,13 +1544,31 @@ func (wv *WebView) runOnGTK(fn func()) {
11721544 glib.IdleAddOnce(cb, 0)
11731545 }
11741546
1547+func (wv *WebView) runOnGTKSync(fn func()) {
1548+ if fn == nil {
1549+ return
1550+ }
1551+ if wv == nil || wv.engine == nil {
1552+ fn()
1553+ return
1554+ }
1555+
1556+ done := make(chan struct{})
1557+ wv.runOnGTK(func() {
1558+ defer close(done)
1559+ fn()
1560+ })
1561+ <-done
1562+}
1563+
11751564 func (wv *WebView) runCloseCallbacks() {
11761565 if wv == nil {
11771566 return
11781567 }
1179- wv.mu.RLock()
1568+ wv.mu.Lock()
11801569 callbacks := append([]func(){}, wv.closeCallbacks...)
1181- wv.mu.RUnlock()
1570+ wv.closeCallbacks = nil
1571+ wv.mu.Unlock()
11821572 if len(callbacks) == 0 {
11831573 return
11841574 }
@@ -23,10 +23,16 @@ import (
23 23
24 // Compile-time interface checks.24 // Compile-time interface checks.
25 var (25 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+ _ port.PopupCapable = (*WebView)(nil)
30+ _ port.PopupNavigationCapable = (*WebView)(nil)
31+ _ port.PopupOpenerBridgeCapable = (*WebView)(nil)
32+ _ port.PopupOpenerMessageCapable = (*WebView)(nil)
33+ _ port.PopupOpenerNavigationCapable = (*WebView)(nil)
34+ _ port.PopupOpenerBridgeStateCapable = (*WebView)(nil)
35+ _ port.OAuthCallbackCapable = (*WebView)(nil)
30 )36 )
31 37
32 // errDestroyed is returned when an operation is attempted on a destroyed WebView.38 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -39,6 +45,7 @@ const (
39 clipboardSelectionDebounceDelay = 300 * time.Millisecond45 clipboardSelectionDebounceDelay = 300 * time.Millisecond
40 pendingNavigationRetryDelay = 50 * time.Millisecond46 pendingNavigationRetryDelay = 50 * time.Millisecond
41 pendingNavigationMaxRetries = 8047 pendingNavigationMaxRetries = 80
48+ nativePopupAttachFallbackDelay = 250 * time.Millisecond
42 )49 )
43 50
44 type stoppableTimer interface {51 type stoppableTimer interface {
@@ -101,8 +108,28 @@ type WebView struct {
101 108
102 // Programmatic popup lifecycle callbacks used for OAuth auto-close and109 // Programmatic popup lifecycle callbacks used for OAuth auto-close and
103 // synthetic window.open() proxy support on CEF.110 // synthetic window.open() proxy support on CEF.
104- closeCallbacks []func()111+ closeCallbacks []func()
105- navigationCallbacks []func(string)112+ navigationCallbacks []func(string)
113+ openerMessageCallbacks []func()
114+ openerNavigationCallbacks []func(string)
115+ popupReadyToShow func()
116+ popupReadyShown bool
117+
118+ // Native popup bookkeeping. CreateRelated() returns a popup shell with no
119+ // browser attached yet; OnBeforePopup may wire CEF's real popup browser into
120+ // that shell. If native attachment is unavailable or stalls, the shell can
121+ // still create its own browser directly while preserving the same popup pane
122+ // lifecycle from the coordinator's perspective.
123+ nativePopupCandidate bool
124+ nativePopupParent *WebView
125+ nativePopupID int32
126+ nativePopupFallbackStarted bool
127+ nativePopupFallbackTimer stoppableTimer
128+ nativePopupFallbackSchedule func(time.Duration, func()) stoppableTimer
129+ pendingNativePopups map[int32]*WebView
130+ popupNoJavaScriptAccess bool
131+ popupOpenerBridgeParent *WebView
132+ popupOpenerBridgeParentURI string
106 133
107 // State cache (mutex-protected).134 // State cache (mutex-protected).
108 uri string135 uri string
@@ -136,6 +163,11 @@ type WebView struct {
136 audioPlaying atomic.Bool163 audioPlaying atomic.Bool
137 zoomFactor atomic.Value // float64, initialized to 1.0164 zoomFactor atomic.Value // float64, initialized to 1.0
138 165
166+ // Browser creation defaults copied from the factory so native popup shells
167+ // can apply the same settings in OnBeforePopup.
168+ windowlessFrameRate int32
169+ backgroundColor uint32
170+
139 // Audio output factory and active stream.171 // Audio output factory and active stream.
140 audioOutputFactory port.AudioOutputFactory172 audioOutputFactory port.AudioOutputFactory
141 audioStreamMu sync.Mutex173 audioStreamMu sync.Mutex
@@ -475,6 +507,56 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
475 wv.callbacks = cb507 wv.callbacks = cb
476 }508 }
477 509
510+// SetOnReadyToShow implements port.PopupCapable.
511+func (wv *WebView) SetOnReadyToShow(fn func()) {
512+ if wv == nil {
513+ return
514+ }
515+ wv.mu.Lock()
516+ wv.popupReadyToShow = fn
517+ alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil
518+ wv.mu.Unlock()
519+ if alreadyReady {
520+ wv.fireReadyToShow()
521+ }
522+}
523+
524+// SetOnClose implements port.PopupCapable.
525+func (wv *WebView) SetOnClose(fn func()) {
526+ wv.AddCloseCallback(fn)
527+}
528+
529+// Show implements port.PopupCapable.
530+func (wv *WebView) Show() {
531+ if wv == nil || wv.destroyed.Load() {
532+ return
533+ }
534+ wv.mu.RLock()
535+ host := wv.host
536+ wv.mu.RUnlock()
537+ if host != nil {
538+ host.WasHidden(0)
539+ }
540+}
541+
542+// PrimePopupNavigation implements port.PopupNavigationCapable.
543+func (wv *WebView) PrimePopupNavigation(uri string) {
544+ if wv == nil || wv.destroyed.Load() {
545+ return
546+ }
547+ actualURI := toActualInternalURL(strings.TrimSpace(uri))
548+ if actualURI == "" {
549+ return
550+ }
551+ wv.mu.Lock()
552+ browser := wv.browser
553+ wv.pendingURI = actualURI
554+ wv.mu.Unlock()
555+ if browser != nil {
556+ wv.schedulePendingNavigationReplay(0)
557+ }
558+}
559+
478 // AddCloseCallback implements port.OAuthCallbackCapable.560 // AddCloseCallback implements port.OAuthCallbackCapable.
479 func (wv *WebView) AddCloseCallback(fn func()) {561 func (wv *WebView) AddCloseCallback(fn func()) {
480 if wv == nil || fn == nil {562 if wv == nil || fn == nil {
@@ -510,6 +592,276 @@ func (wv *WebView) Close() {
510 wv.runCloseCallbacks()592 wv.runCloseCallbacks()
511 }593 }
512 594
595+func (wv *WebView) fireReadyToShow() {
596+ if wv == nil {
597+ return
598+ }
599+ wv.mu.Lock()
600+ if wv.popupReadyShown || wv.popupReadyToShow == nil {
601+ wv.mu.Unlock()
602+ return
603+ }
604+ fn := wv.popupReadyToShow
605+ wv.popupReadyShown = true
606+ wv.mu.Unlock()
607+ wv.runOnGTK(fn)
608+}
609+
610+func (wv *WebView) markNativePopupCandidate(parent *WebView) {
611+ if wv == nil {
612+ return
613+ }
614+ wv.stopNativePopupFallbackTimer()
615+ wv.mu.Lock()
616+ wv.nativePopupCandidate = true
617+ wv.nativePopupParent = parent
618+ wv.nativePopupID = 0
619+ wv.nativePopupFallbackStarted = false
620+ wv.mu.Unlock()
621+}
622+
623+func (wv *WebView) isNativePopupCandidate() bool {
624+ if wv == nil {
625+ return false
626+ }
627+ wv.mu.RLock()
628+ defer wv.mu.RUnlock()
629+ return wv.nativePopupCandidate
630+}
631+
632+func popupParentWindowHandle(parent *WebView) uintptr {
633+ if parent == nil {
634+ return 0
635+ }
636+ parent.mu.RLock()
637+ host := parent.host
638+ parent.mu.RUnlock()
639+ if host == nil {
640+ return 0
641+ }
642+ if handle := host.GetWindowHandle(); handle != 0 {
643+ return handle
644+ }
645+ return host.GetOpenerWindowHandle()
646+}
647+
648+func (wv *WebView) discardNativePopupCandidate() {
649+ if wv == nil {
650+ return
651+ }
652+ wv.mu.Lock()
653+ wv.nativePopupCandidate = false
654+ wv.nativePopupParent = nil
655+ wv.nativePopupID = 0
656+ wv.mu.Unlock()
657+}
658+
659+func (wv *WebView) awaitsNativePopupAttachment() bool {
660+ if wv == nil {
661+ return false
662+ }
663+ wv.mu.RLock()
664+ defer wv.mu.RUnlock()
665+ return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
666+}
667+
668+func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
669+ if wv != nil && wv.nativePopupFallbackSchedule != nil {
670+ return wv.nativePopupFallbackSchedule
671+ }
672+ return func(delay time.Duration, fn func()) stoppableTimer {
673+ return time.AfterFunc(delay, fn)
674+ }
675+}
676+
677+func (wv *WebView) stopNativePopupFallbackTimer() {
678+ if wv == nil {
679+ return
680+ }
681+ wv.mu.Lock()
682+ timer := wv.nativePopupFallbackTimer
683+ wv.nativePopupFallbackTimer = nil
684+ wv.mu.Unlock()
685+ if timer != nil {
686+ timer.Stop()
687+ }
688+}
689+
690+func (wv *WebView) scheduleNativePopupFallback(delay time.Duration, fn func()) {
691+ if wv == nil || fn == nil {
692+ return
693+ }
694+ wv.stopNativePopupFallbackTimer()
695+ var timer stoppableTimer
696+ timer = wv.nativePopupFallbackScheduler()(delay, func() {
697+ wv.mu.Lock()
698+ if wv.nativePopupFallbackTimer != timer {
699+ wv.mu.Unlock()
700+ return
701+ }
702+ wv.nativePopupFallbackTimer = nil
703+ wv.mu.Unlock()
704+ fn()
705+ })
706+ wv.mu.Lock()
707+ wv.nativePopupFallbackTimer = timer
708+ wv.mu.Unlock()
709+}
710+
711+func (wv *WebView) preparePopupShellDirectBrowserCreation() bool {
712+ if wv == nil {
713+ return false
714+ }
715+ wv.stopNativePopupFallbackTimer()
716+ wv.mu.Lock()
717+ if wv.browser != nil || wv.nativePopupFallbackStarted || wv.pendingCreate == nil {
718+ wv.mu.Unlock()
719+ return false
720+ }
721+ parent := wv.nativePopupParent
722+ popupID := wv.nativePopupID
723+ wv.nativePopupFallbackStarted = true
724+ wv.nativePopupCandidate = false
725+ wv.nativePopupParent = nil
726+ wv.nativePopupID = 0
727+ if !wv.popupNoJavaScriptAccess && parent != nil {
728+ wv.popupOpenerBridgeParent = parent
729+ wv.popupOpenerBridgeParentURI = parent.URI()
730+ } else {
731+ wv.popupOpenerBridgeParent = nil
732+ wv.popupOpenerBridgeParentURI = ""
733+ }
734+ wv.mu.Unlock()
735+ if parent != nil && popupID != 0 {
736+ parent.clearPendingNativePopup(popupID, wv)
737+ }
738+ return true
739+}
740+
741+func (wv *WebView) startNativePopupFallback() bool {
742+ if wv == nil {
743+ return false
744+ }
745+ wv.mu.RLock()
746+ alreadyStarted := wv.nativePopupFallbackStarted
747+ wv.mu.RUnlock()
748+ if alreadyStarted {
749+ return false
750+ }
751+ return wv.preparePopupShellDirectBrowserCreation()
752+}
753+
754+func (wv *WebView) trackPendingNativePopup(popupID int32, popup *WebView) {
755+ if wv == nil || popupID == 0 || popup == nil {
756+ return
757+ }
758+ wv.mu.Lock()
759+ if wv.pendingNativePopups == nil {
760+ wv.pendingNativePopups = make(map[int32]*WebView)
761+ }
762+ wv.pendingNativePopups[popupID] = popup
763+ wv.mu.Unlock()
764+}
765+
766+func (wv *WebView) takePendingNativePopup(popupID int32) *WebView {
767+ if wv == nil || popupID == 0 {
768+ return nil
769+ }
770+ wv.mu.Lock()
771+ defer wv.mu.Unlock()
772+ popup := wv.pendingNativePopups[popupID]
773+ delete(wv.pendingNativePopups, popupID)
774+ return popup
775+}
776+
777+func (wv *WebView) clearPendingNativePopup(popupID int32, popup *WebView) {
778+ if wv == nil || popupID == 0 || popup == nil {
779+ return
780+ }
781+ wv.mu.Lock()
782+ defer wv.mu.Unlock()
783+ if current := wv.pendingNativePopups[popupID]; current == popup {
784+ delete(wv.pendingNativePopups, popupID)
785+ }
786+}
787+
788+func (wv *WebView) prepareNativePopup(
789+ popupID int32,
790+ targetURL string,
791+ windowInfo *purecef.WindowInfo,
792+ clientSlot *purecef.RawClientWriteSlot,
793+ settings *purecef.BrowserSettings,
794+) bool {
795+ if wv == nil || wv.destroyed.Load() || popupID == 0 || windowInfo == nil || clientSlot == nil {
796+ return false
797+ }
798+
799+ wv.mu.RLock()
800+ if !wv.nativePopupCandidate || wv.client == nil || wv.nativePopupFallbackStarted || wv.browser != nil {
801+ wv.mu.RUnlock()
802+ return false
803+ }
804+ parent := wv.nativePopupParent
805+ frameRate := wv.windowlessFrameRate
806+ backgroundColor := wv.backgroundColor
807+ wv.mu.RUnlock()
808+
809+ parentWindowHandle := popupParentWindowHandle(parent)
810+ if parentWindowHandle == 0 {
811+ return false
812+ }
813+
814+ wv.mu.Lock()
815+ if !wv.nativePopupCandidate || wv.client == nil || wv.nativePopupFallbackStarted || wv.browser != nil {
816+ wv.mu.Unlock()
817+ return false
818+ }
819+ wv.nativePopupCandidate = false
820+ wv.nativePopupID = popupID
821+ wv.nativePopupFallbackStarted = false
822+ if strings.TrimSpace(wv.pendingURI) == "" {
823+ wv.pendingURI = toActualInternalURL(targetURL)
824+ }
825+ if strings.TrimSpace(wv.pendingURI) != "" {
826+ wv.uri = toConceptualInternalURL(wv.pendingURI)
827+ } else {
828+ wv.uri = toConceptualInternalURL(targetURL)
829+ }
830+ wv.isLoading = true
831+ client := wv.client
832+ wv.mu.Unlock()
833+
834+ if parent != nil {
835+ parent.trackPendingNativePopup(popupID, wv)
836+ }
837+
838+ purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false)
839+ if externalBeginFrameEnabled() {
840+ windowInfo.ExternalBeginFrameEnabled = 1
841+ }
842+ if settings != nil {
843+ if frameRate > 0 {
844+ settings.WindowlessFrameRate = frameRate
845+ }
846+ settings.LocalStorage = 1
847+ if backgroundColor != 0 {
848+ settings.BackgroundColor = backgroundColor
849+ }
850+ }
851+ clientSlot.Set(client)
852+ return true
853+}
854+
855+func (wv *WebView) handleNativePopupAborted() {
856+ if wv == nil {
857+ return
858+ }
859+ wv.mu.Lock()
860+ wv.nativePopupCandidate = false
861+ wv.nativePopupID = 0
862+ wv.mu.Unlock()
863+}
864+
513 // ---------------------------------------------------------------------------865 // ---------------------------------------------------------------------------
514 // JavaScript / Appearance866 // JavaScript / Appearance
515 // ---------------------------------------------------------------------------867 // ---------------------------------------------------------------------------
@@ -554,6 +906,19 @@ func (wv *WebView) rotateBridgeNonce() string {
554 return nonce906 return nonce
555 }907 }
556 908
909+func (wv *WebView) ensureBridgeNonce() string {
910+ if wv == nil {
911+ return ""
912+ }
913+ wv.mu.RLock()
914+ nonce := wv.bridgeNonce
915+ wv.mu.RUnlock()
916+ if nonce != "" {
917+ return nonce
918+ }
919+ return wv.rotateBridgeNonce()
920+}
921+
557 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).922 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
558 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {923 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
559 script := fmt.Sprintf(924 script := fmt.Sprintf(
@@ -587,7 +952,14 @@ func (wv *WebView) Destroy() {
587 wv.mu.Lock()952 wv.mu.Lock()
588 wv.closeCallbacks = nil953 wv.closeCallbacks = nil
589 wv.navigationCallbacks = nil954 wv.navigationCallbacks = nil
955+ wv.popupReadyToShow = nil
956+ wv.pendingNativePopups = nil
957+ wv.nativePopupParent = nil
958+ wv.nativePopupCandidate = false
959+ wv.nativePopupID = 0
960+ wv.nativePopupFallbackStarted = false
590 wv.mu.Unlock()961 wv.mu.Unlock()
962+ wv.stopNativePopupFallbackTimer()
591 wv.cancelSelectionDebounce()963 wv.cancelSelectionDebounce()
592 if wv.resizeReconciler != nil {964 if wv.resizeReconciler != nil {
593 wv.resizeReconciler.stop()965 wv.resizeReconciler.stop()
@@ -1172,13 +1544,31 @@ func (wv *WebView) runOnGTK(fn func()) {
1172 glib.IdleAddOnce(cb, 0)1544 glib.IdleAddOnce(cb, 0)
1173 }1545 }
1174 1546
1547+func (wv *WebView) runOnGTKSync(fn func()) {
1548+ if fn == nil {
1549+ return
1550+ }
1551+ if wv == nil || wv.engine == nil {
1552+ fn()
1553+ return
1554+ }
1555+
1556+ done := make(chan struct{})
1557+ wv.runOnGTK(func() {
1558+ defer close(done)
1559+ fn()
1560+ })
1561+ <-done
1562+}
1563+
1175 func (wv *WebView) runCloseCallbacks() {1564 func (wv *WebView) runCloseCallbacks() {
1176 if wv == nil {1565 if wv == nil {
1177 return1566 return
1178 }1567 }
1179- wv.mu.RLock()1568+ wv.mu.Lock()
1180 callbacks := append([]func(){}, wv.closeCallbacks...)1569 callbacks := append([]func(){}, wv.closeCallbacks...)
1181- wv.mu.RUnlock()1570+ wv.closeCallbacks = nil
1571+ wv.mu.Unlock()
1182 if len(callbacks) == 0 {1572 if len(callbacks) == 0 {
1183 return1573 return
1184 }1574 }
M internal/infrastructure/cef/webview_bridge_nonce_test.go +5 -0
@@ -55,3 +55,8 @@ func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) {
5555 defer wv.mu.RUnlock()
5656 require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)
5757 }
58+
59+func TestWebViewEnsureBridgeNonce_ReusesExistingNonce(t *testing.T) {
60+ wv := &WebView{bridgeNonce: "existing-nonce"}
61+ require.Equal(t, "existing-nonce", wv.ensureBridgeNonce())
62+}
@@ -55,3 +55,8 @@ func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) {
55 defer wv.mu.RUnlock()55 defer wv.mu.RUnlock()
56 require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)56 require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)
57 }57 }
58+
59+func TestWebViewEnsureBridgeNonce_ReusesExistingNonce(t *testing.T) {
60+ wv := &WebView{bridgeNonce: "existing-nonce"}
61+ require.Equal(t, "existing-nonce", wv.ensureBridgeNonce())
62+}
M internal/infrastructure/cef/webview_handlers.go +94 -38
@@ -601,6 +601,10 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
601601 if frame == nil || !frame.IsMain() {
602602 return
603603 }
604+ bridgeNonce := h.wv.rotateBridgeNonce()
605+ if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
606+ frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
607+ }
604608 if h.wv != nil && h.wv.ctx != nil {
605609 logging.FromContext(h.wv.ctx).Debug().
606610 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
@@ -672,12 +676,14 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
672676 // ===========================================================================
673677
674678 // OnBeforePopup intercepts popup requests (target="_blank", window.open).
675-// CEF OSR cannot create popup windows, so we fire the OnCreate callback
676-// to let the coordinator open the link in a new stacked pane.
679+// When the coordinator returns a related CEF popup shell we hand that shell's
680+// client back to CEF and allow native popup creation so opener semantics are
681+// preserved. Otherwise we keep blocking and let the coordinator's fallback
682+// pane handle the navigation.
677683 func (h *handlerSet) OnBeforePopup(
678- _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
684+ _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
679685 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
680- _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,
686+ windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
681687 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
682688 ) bool {
683689 if targetURL == "" {
@@ -687,28 +693,47 @@ func (h *handlerSet) OnBeforePopup(
687693 h.wv.mu.RLock()
688694 cb := h.wv.callbacks
689695 h.wv.mu.RUnlock()
696+ if cb == nil || cb.OnCreate == nil {
697+ return true
698+ }
690699
691- if cb != nil && cb.OnCreate != nil {
692- requestNoJavaScriptAccess := false
693- if noJavaScriptAccess != nil {
694- requestNoJavaScriptAccess = *noJavaScriptAccess
695- }
696- req := port.PopupRequest{
700+ requestNoJavaScriptAccess := false
701+ if noJavaScriptAccess != nil {
702+ requestNoJavaScriptAccess = *noJavaScriptAccess
703+ }
704+ var popup port.WebView
705+ h.wv.runOnGTKSync(func() {
706+ popup = cb.OnCreate(port.PopupRequest{
697707 TargetURI: targetURL,
698708 FrameName: targetFrameName,
699709 IsUserGesture: userGesture != 0,
700710 NoJavaScriptAccess: requestNoJavaScriptAccess,
701711 ParentViewID: h.wv.id,
702- }
703- h.wv.runOnGTK(func() {
704- cb.OnCreate(req)
705712 })
713+ })
714+
715+ cefPopup, ok := popup.(*WebView)
716+ if !ok || cefPopup == nil {
717+ return true
718+ }
719+ cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
720+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
721+ return false
706722 }
707723
708- return true // always block CEF popup; the coordinator handles the new pane
724+ cefPopup.discardNativePopupCandidate()
725+ logging.FromContext(h.currentContext()).Debug().
726+ Int32("popup_id", popupID).
727+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
728+ Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
729+ return true
709730 }
710731
711-func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {}
732+func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
733+ if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
734+ popup.handleNativePopupAborted()
735+ }
736+}
712737
713738 func (h *handlerSet) OnBeforeDevToolsPopup(
714739 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
@@ -727,11 +752,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
727752 log.Debug().
728753 Int32("browser_id", browserID).
729754 Msg("cef: OnAfterCreated")
730- if h.wv.engine != nil {
731- h.wv.engine.recordBrowserAfterCreated(browser)
732- h.wv.engine.registerWebView(h.wv)
733- h.wv.engine.bindBrowserWebView(browser, h.wv)
734- }
735755 host := browser.GetHost()
736756 if host == nil {
737757 log.Warn().Msg("cef: OnAfterCreated returned nil host")
@@ -740,29 +760,61 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
740760
741761 shouldSyncFocus := false
742762 hasPendingNavigation := false
763+ nativePopupParent := (*WebView)(nil)
764+ nativePopupID := int32(0)
765+ duplicateBrowserID := int32(0)
766+ closeDuplicate := false
743767 h.wv.mu.Lock()
744- h.wv.browser = browser
745- h.wv.host = host
746- h.wv.input.setHost(host)
747- if h.wv.findCtrl != nil {
748- h.wv.findCtrl.setHost(host)
768+ if existing := h.wv.browser; existing != nil {
769+ existingID := existing.GetIdentifier()
770+ if existingID != 0 && existingID != browserID {
771+ duplicateBrowserID = existingID
772+ closeDuplicate = true
773+ }
749774 }
750-
751- // Mark browser as visible — CEF OSR starts in hidden state and suppresses
752- // painting/caret updates until explicitly told the browser is shown.
753- host.WasHidden(0)
754-
755- // If GTK focus entered before the browser existed, reassert browser focus
756- // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
757- if h.wv.input != nil {
758- shouldSyncFocus = h.wv.input.hasGTKFocus()
759- if !shouldSyncFocus && h.wv.input.glArea != nil {
760- shouldSyncFocus = h.wv.input.glArea.HasFocus()
775+ if !closeDuplicate {
776+ h.wv.browser = browser
777+ h.wv.host = host
778+ h.wv.pendingCreate = nil
779+ h.wv.input.setHost(host)
780+ if h.wv.findCtrl != nil {
781+ h.wv.findCtrl.setHost(host)
782+ }
783+ nativePopupParent = h.wv.nativePopupParent
784+ nativePopupID = h.wv.nativePopupID
785+ h.wv.nativePopupParent = nil
786+ h.wv.nativePopupID = 0
787+ h.wv.nativePopupFallbackStarted = false
788+
789+ // Mark browser as visible — CEF OSR starts in hidden state and suppresses
790+ // painting/caret updates until explicitly told the browser is shown.
791+ host.WasHidden(0)
792+
793+ // If GTK focus entered before the browser existed, reassert browser focus
794+ // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
795+ if h.wv.input != nil {
796+ shouldSyncFocus = h.wv.input.hasGTKFocus()
797+ if !shouldSyncFocus && h.wv.input.glArea != nil {
798+ shouldSyncFocus = h.wv.input.glArea.HasFocus()
799+ }
761800 }
801+ hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
762802 }
763- hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
764-
765803 h.wv.mu.Unlock()
804+ if closeDuplicate {
805+ log.Warn().
806+ Int32("browser_id", browserID).
807+ Int32("existing_browser_id", duplicateBrowserID).
808+ Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
809+ host.CloseBrowser(1)
810+ return
811+ }
812+ if h.wv.engine != nil {
813+ h.wv.engine.recordBrowserAfterCreated(browser)
814+ h.wv.engine.registerWebView(h.wv)
815+ h.wv.engine.bindBrowserWebView(browser, h.wv)
816+ }
817+ h.wv.stopNativePopupFallbackTimer()
766818 if shouldSyncFocus {
767819 syncWindowlessBrowserFocus(host)
768820 } else {
@@ -774,8 +826,12 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
774826 if hasPendingNavigation {
775827 h.wv.schedulePendingNavigationReplay(0)
776828 }
829+ if nativePopupParent != nil && nativePopupID != 0 {
830+ nativePopupParent.clearPendingNativePopup(nativePopupID, h.wv)
831+ }
777832
778833 h.wv.scheduleStartBeginFrameLoop()
834+ h.wv.fireReadyToShow()
779835 }
780836
781837 // DoClose returns false to allow the default close behavior.
@@ -601,6 +601,10 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
601 if frame == nil || !frame.IsMain() {601 if frame == nil || !frame.IsMain() {
602 return602 return
603 }603 }
604+ bridgeNonce := h.wv.rotateBridgeNonce()
605+ if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
606+ frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
607+ }
604 if h.wv != nil && h.wv.ctx != nil {608 if h.wv != nil && h.wv.ctx != nil {
605 logging.FromContext(h.wv.ctx).Debug().609 logging.FromContext(h.wv.ctx).Debug().
606 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).610 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
@@ -672,12 +676,14 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
672 // ===========================================================================676 // ===========================================================================
673 677
674 // OnBeforePopup intercepts popup requests (target="_blank", window.open).678 // OnBeforePopup intercepts popup requests (target="_blank", window.open).
675-// CEF OSR cannot create popup windows, so we fire the OnCreate callback679+// When the coordinator returns a related CEF popup shell we hand that shell's
676-// to let the coordinator open the link in a new stacked pane.680+// client back to CEF and allow native popup creation so opener semantics are
681+// preserved. Otherwise we keep blocking and let the coordinator's fallback
682+// pane handle the navigation.
677 func (h *handlerSet) OnBeforePopup(683 func (h *handlerSet) OnBeforePopup(
678- _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,684+ _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
679 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,685 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
680- _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,686+ windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
681 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,687 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
682 ) bool {688 ) bool {
683 if targetURL == "" {689 if targetURL == "" {
@@ -687,28 +693,47 @@ func (h *handlerSet) OnBeforePopup(
687 h.wv.mu.RLock()693 h.wv.mu.RLock()
688 cb := h.wv.callbacks694 cb := h.wv.callbacks
689 h.wv.mu.RUnlock()695 h.wv.mu.RUnlock()
696+ if cb == nil || cb.OnCreate == nil {
697+ return true
698+ }
690 699
691- if cb != nil && cb.OnCreate != nil {700+ requestNoJavaScriptAccess := false
692- requestNoJavaScriptAccess := false701+ if noJavaScriptAccess != nil {
693- if noJavaScriptAccess != nil {702+ requestNoJavaScriptAccess = *noJavaScriptAccess
694- requestNoJavaScriptAccess = *noJavaScriptAccess703+ }
695- }704+ var popup port.WebView
696- req := port.PopupRequest{705+ h.wv.runOnGTKSync(func() {
706+ popup = cb.OnCreate(port.PopupRequest{
697 TargetURI: targetURL,707 TargetURI: targetURL,
698 FrameName: targetFrameName,708 FrameName: targetFrameName,
699 IsUserGesture: userGesture != 0,709 IsUserGesture: userGesture != 0,
700 NoJavaScriptAccess: requestNoJavaScriptAccess,710 NoJavaScriptAccess: requestNoJavaScriptAccess,
701 ParentViewID: h.wv.id,711 ParentViewID: h.wv.id,
702- }
703- h.wv.runOnGTK(func() {
704- cb.OnCreate(req)
705 })712 })
713+ })
714+
715+ cefPopup, ok := popup.(*WebView)
716+ if !ok || cefPopup == nil {
717+ return true
718+ }
719+ cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
720+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
721+ return false
706 }722 }
707 723
708- return true // always block CEF popup; the coordinator handles the new pane724+ cefPopup.discardNativePopupCandidate()
725+ logging.FromContext(h.currentContext()).Debug().
726+ Int32("popup_id", popupID).
727+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
728+ Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
729+ return true
709 }730 }
710 731
711-func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {}732+func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
733+ if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
734+ popup.handleNativePopupAborted()
735+ }
736+}
712 737
713 func (h *handlerSet) OnBeforeDevToolsPopup(738 func (h *handlerSet) OnBeforeDevToolsPopup(
714 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,739 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
@@ -727,11 +752,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
727 log.Debug().752 log.Debug().
728 Int32("browser_id", browserID).753 Int32("browser_id", browserID).
729 Msg("cef: OnAfterCreated")754 Msg("cef: OnAfterCreated")
730- if h.wv.engine != nil {
731- h.wv.engine.recordBrowserAfterCreated(browser)
732- h.wv.engine.registerWebView(h.wv)
733- h.wv.engine.bindBrowserWebView(browser, h.wv)
734- }
735 host := browser.GetHost()755 host := browser.GetHost()
736 if host == nil {756 if host == nil {
737 log.Warn().Msg("cef: OnAfterCreated returned nil host")757 log.Warn().Msg("cef: OnAfterCreated returned nil host")
@@ -740,29 +760,61 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
740 760
741 shouldSyncFocus := false761 shouldSyncFocus := false
742 hasPendingNavigation := false762 hasPendingNavigation := false
763+ nativePopupParent := (*WebView)(nil)
764+ nativePopupID := int32(0)
765+ duplicateBrowserID := int32(0)
766+ closeDuplicate := false
743 h.wv.mu.Lock()767 h.wv.mu.Lock()
744- h.wv.browser = browser768+ if existing := h.wv.browser; existing != nil {
745- h.wv.host = host769+ existingID := existing.GetIdentifier()
746- h.wv.input.setHost(host)770+ if existingID != 0 && existingID != browserID {
747- if h.wv.findCtrl != nil {771+ duplicateBrowserID = existingID
748- h.wv.findCtrl.setHost(host)772+ closeDuplicate = true
773+ }
749 }774 }
750-775+ if !closeDuplicate {
751- // Mark browser as visible — CEF OSR starts in hidden state and suppresses776+ h.wv.browser = browser
752- // painting/caret updates until explicitly told the browser is shown.777+ h.wv.host = host
753- host.WasHidden(0)778+ h.wv.pendingCreate = nil
754-779+ h.wv.input.setHost(host)
755- // If GTK focus entered before the browser existed, reassert browser focus780+ if h.wv.findCtrl != nil {
756- // now. Otherwise CEF may stay internally unfocused and suppress caret paint.781+ h.wv.findCtrl.setHost(host)
757- if h.wv.input != nil {782+ }
758- shouldSyncFocus = h.wv.input.hasGTKFocus()783+ nativePopupParent = h.wv.nativePopupParent
759- if !shouldSyncFocus && h.wv.input.glArea != nil {784+ nativePopupID = h.wv.nativePopupID
760- shouldSyncFocus = h.wv.input.glArea.HasFocus()785+ h.wv.nativePopupParent = nil
786+ h.wv.nativePopupID = 0
787+ h.wv.nativePopupFallbackStarted = false
788+
789+ // Mark browser as visible — CEF OSR starts in hidden state and suppresses
790+ // painting/caret updates until explicitly told the browser is shown.
791+ host.WasHidden(0)
792+
793+ // If GTK focus entered before the browser existed, reassert browser focus
794+ // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
795+ if h.wv.input != nil {
796+ shouldSyncFocus = h.wv.input.hasGTKFocus()
797+ if !shouldSyncFocus && h.wv.input.glArea != nil {
798+ shouldSyncFocus = h.wv.input.glArea.HasFocus()
799+ }
761 }800 }
801+ hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
762 }802 }
763- hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
764-
765 h.wv.mu.Unlock()803 h.wv.mu.Unlock()
804+ if closeDuplicate {
805+ log.Warn().
806+ Int32("browser_id", browserID).
807+ Int32("existing_browser_id", duplicateBrowserID).
808+ Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
809+ host.CloseBrowser(1)
810+ return
811+ }
812+ if h.wv.engine != nil {
813+ h.wv.engine.recordBrowserAfterCreated(browser)
814+ h.wv.engine.registerWebView(h.wv)
815+ h.wv.engine.bindBrowserWebView(browser, h.wv)
816+ }
817+ h.wv.stopNativePopupFallbackTimer()
766 if shouldSyncFocus {818 if shouldSyncFocus {
767 syncWindowlessBrowserFocus(host)819 syncWindowlessBrowserFocus(host)
768 } else {820 } else {
@@ -774,8 +826,12 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
774 if hasPendingNavigation {826 if hasPendingNavigation {
775 h.wv.schedulePendingNavigationReplay(0)827 h.wv.schedulePendingNavigationReplay(0)
776 }828 }
829+ if nativePopupParent != nil && nativePopupID != 0 {
830+ nativePopupParent.clearPendingNativePopup(nativePopupID, h.wv)
831+ }
777 832
778 h.wv.scheduleStartBeginFrameLoop()833 h.wv.scheduleStartBeginFrameLoop()
834+ h.wv.fireReadyToShow()
779 }835 }
780 836
781 // DoClose returns false to allow the default close behavior.837 // DoClose returns false to allow the default close behavior.
M internal/infrastructure/cef/webview_handlers_test.go +89 -0
@@ -101,6 +101,95 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
101101 return nil
102102 }
103103
104+func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
105+ wv := &WebView{
106+ nativePopupCandidate: true,
107+ pendingCreate: &pendingBrowserCreate{},
108+ }
109+
110+ require.True(t, wv.startNativePopupFallback())
111+ require.False(t, wv.isNativePopupCandidate())
112+ require.True(t, wv.nativePopupFallbackStarted)
113+}
114+
115+func TestPreparePopupShellDirectBrowserCreation_EnablesSyntheticOpenerBridge(t *testing.T) {
116+ parent := &WebView{ctx: context.Background()}
117+ parent.updateURI("https://example.com/login")
118+ wv := &WebView{
119+ pendingCreate: &pendingBrowserCreate{},
120+ nativePopupParent: parent,
121+ }
122+
123+ require.True(t, wv.preparePopupShellDirectBrowserCreation())
124+ require.Same(t, parent, wv.popupOpenerBridgeParent)
125+ require.Equal(t, "https://example.com/login", wv.popupOpenerBridgeParentURI)
126+}
127+
128+func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) {
129+ closed := false
130+ parent := &WebView{}
131+ wv := &WebView{
132+ nativePopupCandidate: true,
133+ nativePopupParent: parent,
134+ nativePopupID: 55,
135+ pendingCreate: &pendingBrowserCreate{},
136+ pendingURI: "https://example.com/oauth",
137+ isLoading: true,
138+ closeCallbacks: []func(){
139+ func() { closed = true },
140+ },
141+ }
142+
143+ wv.handleNativePopupAborted()
144+
145+ require.False(t, closed)
146+ require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
147+ require.False(t, wv.isNativePopupCandidate())
148+ require.Same(t, parent, wv.nativePopupParent)
149+ require.Equal(t, int32(0), wv.nativePopupID)
150+ require.True(t, wv.isLoading)
151+}
152+
153+func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) {
154+ parentWV := &WebView{ctx: context.Background(), id: 17}
155+ popupWV := &WebView{ctx: context.Background(), id: 23}
156+ parentWV.SetCallbacks(&port.WebViewCallbacks{
157+ OnCreate: func(req port.PopupRequest) port.WebView {
158+ require.Equal(t, "https://example.com/popup", req.TargetURI)
159+ require.Equal(t, "auth-popup", req.FrameName)
160+ require.True(t, req.IsUserGesture)
161+ popupWV.PrimePopupNavigation(req.TargetURI)
162+ return popupWV
163+ },
164+ })
165+
166+ h := &handlerSet{wv: parentWV}
167+ blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil)
168+
169+ require.True(t, blocked)
170+ require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI())
171+}
172+
173+func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) {
174+ parentWV := &WebView{ctx: context.Background(), id: 31}
175+ popupWV := &WebView{ctx: context.Background(), id: 32}
176+ popupWV.markNativePopupCandidate(parentWV)
177+ parentWV.SetCallbacks(&port.WebViewCallbacks{
178+ OnCreate: func(req port.PopupRequest) port.WebView {
179+ require.Equal(t, "https://example.com/login", req.TargetURI)
180+ popupWV.PrimePopupNavigation(req.TargetURI)
181+ return popupWV
182+ },
183+ })
184+
185+ h := &handlerSet{wv: parentWV}
186+ blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil)
187+
188+ require.True(t, blocked)
189+ require.False(t, popupWV.isNativePopupCandidate())
190+ require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
191+}
192+
104193 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
105194 wv := &WebView{ctx: context.Background()}
106195 var gotEvents []port.LoadEvent
@@ -101,6 +101,95 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
101 return nil101 return nil
102 }102 }
103 103
104+func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
105+ wv := &WebView{
106+ nativePopupCandidate: true,
107+ pendingCreate: &pendingBrowserCreate{},
108+ }
109+
110+ require.True(t, wv.startNativePopupFallback())
111+ require.False(t, wv.isNativePopupCandidate())
112+ require.True(t, wv.nativePopupFallbackStarted)
113+}
114+
115+func TestPreparePopupShellDirectBrowserCreation_EnablesSyntheticOpenerBridge(t *testing.T) {
116+ parent := &WebView{ctx: context.Background()}
117+ parent.updateURI("https://example.com/login")
118+ wv := &WebView{
119+ pendingCreate: &pendingBrowserCreate{},
120+ nativePopupParent: parent,
121+ }
122+
123+ require.True(t, wv.preparePopupShellDirectBrowserCreation())
124+ require.Same(t, parent, wv.popupOpenerBridgeParent)
125+ require.Equal(t, "https://example.com/login", wv.popupOpenerBridgeParentURI)
126+}
127+
128+func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) {
129+ closed := false
130+ parent := &WebView{}
131+ wv := &WebView{
132+ nativePopupCandidate: true,
133+ nativePopupParent: parent,
134+ nativePopupID: 55,
135+ pendingCreate: &pendingBrowserCreate{},
136+ pendingURI: "https://example.com/oauth",
137+ isLoading: true,
138+ closeCallbacks: []func(){
139+ func() { closed = true },
140+ },
141+ }
142+
143+ wv.handleNativePopupAborted()
144+
145+ require.False(t, closed)
146+ require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
147+ require.False(t, wv.isNativePopupCandidate())
148+ require.Same(t, parent, wv.nativePopupParent)
149+ require.Equal(t, int32(0), wv.nativePopupID)
150+ require.True(t, wv.isLoading)
151+}
152+
153+func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) {
154+ parentWV := &WebView{ctx: context.Background(), id: 17}
155+ popupWV := &WebView{ctx: context.Background(), id: 23}
156+ parentWV.SetCallbacks(&port.WebViewCallbacks{
157+ OnCreate: func(req port.PopupRequest) port.WebView {
158+ require.Equal(t, "https://example.com/popup", req.TargetURI)
159+ require.Equal(t, "auth-popup", req.FrameName)
160+ require.True(t, req.IsUserGesture)
161+ popupWV.PrimePopupNavigation(req.TargetURI)
162+ return popupWV
163+ },
164+ })
165+
166+ h := &handlerSet{wv: parentWV}
167+ blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil)
168+
169+ require.True(t, blocked)
170+ require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI())
171+}
172+
173+func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) {
174+ parentWV := &WebView{ctx: context.Background(), id: 31}
175+ popupWV := &WebView{ctx: context.Background(), id: 32}
176+ popupWV.markNativePopupCandidate(parentWV)
177+ parentWV.SetCallbacks(&port.WebViewCallbacks{
178+ OnCreate: func(req port.PopupRequest) port.WebView {
179+ require.Equal(t, "https://example.com/login", req.TargetURI)
180+ popupWV.PrimePopupNavigation(req.TargetURI)
181+ return popupWV
182+ },
183+ })
184+
185+ h := &handlerSet{wv: parentWV}
186+ blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil)
187+
188+ require.True(t, blocked)
189+ require.False(t, popupWV.isNativePopupCandidate())
190+ require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
191+}
192+
104 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {193 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
105 wv := &WebView{ctx: context.Background()}194 wv := &WebView{ctx: context.Background()}
106 var gotEvents []port.LoadEvent195 var gotEvents []port.LoadEvent
M internal/infrastructure/cef/webview_popup_proxy.go +44 -10
@@ -12,6 +12,7 @@ type syntheticPopupState struct {
1212 WebView port.WebView
1313 PendingURI string
1414 NoJavaScriptAccess bool
15+ Closed bool
1516 }
1617
1718 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
@@ -55,9 +56,13 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
5556 }
5657
5758 wv.syntheticPopupMu.Lock()
58- if state := wv.syntheticPopups[proxyID]; state != nil {
59- state.NoJavaScriptAccess = noJavaScriptAccess
59+ state := wv.syntheticPopupStateLocked(proxyID)
60+ if state.Closed {
61+ delete(wv.syntheticPopups, proxyID)
62+ wv.syntheticPopupMu.Unlock()
63+ return
6064 }
65+ state.NoJavaScriptAccess = noJavaScriptAccess
6166 wv.syntheticPopupMu.Unlock()
6267
6368 wv.runOnGTK(func() {
@@ -65,6 +70,14 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
6570 return
6671 }
6772
73+ wv.syntheticPopupMu.Lock()
74+ if state := wv.syntheticPopups[proxyID]; state != nil && state.Closed {
75+ delete(wv.syntheticPopups, proxyID)
76+ wv.syntheticPopupMu.Unlock()
77+ return
78+ }
79+ wv.syntheticPopupMu.Unlock()
80+
6881 wv.mu.RLock()
6982 cb := wv.callbacks
7083 wv.mu.RUnlock()
@@ -84,10 +97,19 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
8497 wv.deleteSyntheticPopupState(proxyID)
8598 return
8699 }
100+ if cefPopup, ok := popupWV.(*WebView); ok {
101+ cefPopup.setPopupNoJavaScriptAccess(noJavaScriptAccess)
102+ }
87103
88104 pendingURI := ""
89105 wv.syntheticPopupMu.Lock()
90106 state := wv.syntheticPopupStateLocked(proxyID)
107+ if state.Closed {
108+ delete(wv.syntheticPopups, proxyID)
109+ wv.syntheticPopupMu.Unlock()
110+ closeSyntheticPopupWebView(popupWV)
111+ return
112+ }
91113 state.WebView = popupWV
92114 state.NoJavaScriptAccess = noJavaScriptAccess
93115 pendingURI = strings.TrimSpace(state.PendingURI)
@@ -118,6 +140,10 @@ func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
118140 }
119141
120142 wv.syntheticPopupMu.Lock()
143+ if state.Closed {
144+ wv.syntheticPopupMu.Unlock()
145+ return
146+ }
121147 state.PendingURI = trimmedURI
122148 popupWV := state.WebView
123149 wv.syntheticPopupMu.Unlock()
@@ -149,21 +175,29 @@ func (wv *WebView) handleSyntheticPopupClose(proxyID string) {
149175 }
150176
151177 wv.syntheticPopupMu.Lock()
152- state := wv.syntheticPopups[proxyID]
153- if state != nil {
178+ state := wv.syntheticPopupStateLocked(proxyID)
179+ state.Closed = true
180+ popupWV := state.WebView
181+ if popupWV != nil {
154182 delete(wv.syntheticPopups, proxyID)
155183 }
156184 wv.syntheticPopupMu.Unlock()
157- if state == nil || state.WebView == nil {
185+ if popupWV == nil {
158186 return
159187 }
160188
161- closeable, ok := state.WebView.(port.OAuthCallbackCapable)
162- if !ok {
189+ wv.runOnGTK(func() {
190+ closeSyntheticPopupWebView(popupWV)
191+ })
192+}
193+
194+func closeSyntheticPopupWebView(popupWV port.WebView) {
195+ if popupWV == nil {
163196 return
164197 }
165-
166- wv.runOnGTK(func() {
198+ if closeable, ok := popupWV.(port.OAuthCallbackCapable); ok {
167199 closeable.Close()
168- })
200+ return
201+ }
202+ popupWV.Destroy()
169203 }
@@ -12,6 +12,7 @@ type syntheticPopupState struct {
12 WebView port.WebView12 WebView port.WebView
13 PendingURI string13 PendingURI string
14 NoJavaScriptAccess bool14 NoJavaScriptAccess bool
15+ Closed bool
15 }16 }
16 17
17 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {18 func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
@@ -55,9 +56,13 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
55 }56 }
56 57
57 wv.syntheticPopupMu.Lock()58 wv.syntheticPopupMu.Lock()
58- if state := wv.syntheticPopups[proxyID]; state != nil {59+ state := wv.syntheticPopupStateLocked(proxyID)
59- state.NoJavaScriptAccess = noJavaScriptAccess60+ if state.Closed {
61+ delete(wv.syntheticPopups, proxyID)
62+ wv.syntheticPopupMu.Unlock()
63+ return
60 }64 }
65+ state.NoJavaScriptAccess = noJavaScriptAccess
61 wv.syntheticPopupMu.Unlock()66 wv.syntheticPopupMu.Unlock()
62 67
63 wv.runOnGTK(func() {68 wv.runOnGTK(func() {
@@ -65,6 +70,14 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
65 return70 return
66 }71 }
67 72
73+ wv.syntheticPopupMu.Lock()
74+ if state := wv.syntheticPopups[proxyID]; state != nil && state.Closed {
75+ delete(wv.syntheticPopups, proxyID)
76+ wv.syntheticPopupMu.Unlock()
77+ return
78+ }
79+ wv.syntheticPopupMu.Unlock()
80+
68 wv.mu.RLock()81 wv.mu.RLock()
69 cb := wv.callbacks82 cb := wv.callbacks
70 wv.mu.RUnlock()83 wv.mu.RUnlock()
@@ -84,10 +97,19 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
84 wv.deleteSyntheticPopupState(proxyID)97 wv.deleteSyntheticPopupState(proxyID)
85 return98 return
86 }99 }
100+ if cefPopup, ok := popupWV.(*WebView); ok {
101+ cefPopup.setPopupNoJavaScriptAccess(noJavaScriptAccess)
102+ }
87 103
88 pendingURI := ""104 pendingURI := ""
89 wv.syntheticPopupMu.Lock()105 wv.syntheticPopupMu.Lock()
90 state := wv.syntheticPopupStateLocked(proxyID)106 state := wv.syntheticPopupStateLocked(proxyID)
107+ if state.Closed {
108+ delete(wv.syntheticPopups, proxyID)
109+ wv.syntheticPopupMu.Unlock()
110+ closeSyntheticPopupWebView(popupWV)
111+ return
112+ }
91 state.WebView = popupWV113 state.WebView = popupWV
92 state.NoJavaScriptAccess = noJavaScriptAccess114 state.NoJavaScriptAccess = noJavaScriptAccess
93 pendingURI = strings.TrimSpace(state.PendingURI)115 pendingURI = strings.TrimSpace(state.PendingURI)
@@ -118,6 +140,10 @@ func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
118 }140 }
119 141
120 wv.syntheticPopupMu.Lock()142 wv.syntheticPopupMu.Lock()
143+ if state.Closed {
144+ wv.syntheticPopupMu.Unlock()
145+ return
146+ }
121 state.PendingURI = trimmedURI147 state.PendingURI = trimmedURI
122 popupWV := state.WebView148 popupWV := state.WebView
123 wv.syntheticPopupMu.Unlock()149 wv.syntheticPopupMu.Unlock()
@@ -149,21 +175,29 @@ func (wv *WebView) handleSyntheticPopupClose(proxyID string) {
149 }175 }
150 176
151 wv.syntheticPopupMu.Lock()177 wv.syntheticPopupMu.Lock()
152- state := wv.syntheticPopups[proxyID]178+ state := wv.syntheticPopupStateLocked(proxyID)
153- if state != nil {179+ state.Closed = true
180+ popupWV := state.WebView
181+ if popupWV != nil {
154 delete(wv.syntheticPopups, proxyID)182 delete(wv.syntheticPopups, proxyID)
155 }183 }
156 wv.syntheticPopupMu.Unlock()184 wv.syntheticPopupMu.Unlock()
157- if state == nil || state.WebView == nil {185+ if popupWV == nil {
158 return186 return
159 }187 }
160 188
161- closeable, ok := state.WebView.(port.OAuthCallbackCapable)189+ wv.runOnGTK(func() {
162- if !ok {190+ closeSyntheticPopupWebView(popupWV)
191+ })
192+}
193+
194+func closeSyntheticPopupWebView(popupWV port.WebView) {
195+ if popupWV == nil {
163 return196 return
164 }197 }
165-198+ if closeable, ok := popupWV.(port.OAuthCallbackCapable); ok {
166- wv.runOnGTK(func() {
167 closeable.Close()199 closeable.Close()
168- })200+ return
201+ }
202+ popupWV.Destroy()
169 }203 }
M internal/ui/coordinator/content/oauth.go +48 -4
@@ -227,6 +227,14 @@ func composeOnLoadChanged(
227227 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
228228 // window.close() which triggers WebKit's close signal.
229229 // A long safety timeout (30s) catches popups that get stuck.
230+func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
231+ if wv == nil {
232+ return false
233+ }
234+ state, ok := wv.(port.PopupOpenerBridgeStateCapable)
235+ return ok && state.HasActivePopupOpenerBridge()
236+}
237+
230238 func (c *Coordinator) setupOAuthAutoClose(
231239 ctx context.Context,
232240 paneID entity.PaneID,
@@ -287,8 +295,7 @@ func (c *Coordinator) setupOAuthAutoClose(
287295 })
288296 }
289297
290- requestOAuthClose := func(uri string, reason string) {
291- c.capturePopupOAuthState(popupID, uri)
298+ requestOAuthClose := func(reason string) {
292299 cancelSafetyTimer()
293300 log.Info().
294301 Str("pane", string(paneID)).
@@ -309,13 +316,32 @@ func (c *Coordinator) setupOAuthAutoClose(
309316
310317 // Start safety timer immediately.
311318 startSafetyTimer()
319+ deferCloseOnNavigation := popupUsesSyntheticOpenerSignals(wv)
312320
313321 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314322 oauthWV.AddNavigationCallback(func(uri string) {
315- if ShouldAutoClose(uri) {
316- requestOAuthClose(uri, "navigation")
323+ if !ShouldAutoClose(uri) {
324+ return
325+ }
326+ c.capturePopupOAuthState(popupID, uri)
327+ if deferCloseOnNavigation {
328+ cancelSafetyTimer()
329+ return
317330 }
331+ requestOAuthClose("navigation")
318332 })
333+ if openerNavigations, ok := wv.(port.PopupOpenerNavigationCapable); ok {
334+ openerNavigations.AddOpenerNavigationCallback(func(uri string) {
335+ c.capturePopupOAuthState(popupID, uri)
336+ requestOAuthClose("opener-navigation")
337+ })
338+ }
339+ if openerMessages, ok := wv.(port.PopupOpenerMessageCapable); ok {
340+ openerMessages.AddOpenerMessageCallback(func() {
341+ c.capturePopupOAuthMessage(popupID)
342+ requestOAuthClose("opener-message")
343+ })
344+ }
319345
320346 // Cancel safety timer on any close path.
321347 oauthWV.AddCloseCallback(func() {
@@ -350,6 +376,24 @@ func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string)
350376 state.Error = IsOAuthError(uri)
351377 }
352378
379+func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
380+ c.popupMu.Lock()
381+ defer c.popupMu.Unlock()
382+
383+ state, ok := c.popupOAuth[popupID]
384+ if !ok {
385+ return
386+ }
387+
388+ state.Seen = true
389+ if state.CallbackURI == "" {
390+ state.CallbackURI = "postmessage://oauth-complete"
391+ }
392+ if !state.Error {
393+ state.Success = true
394+ }
395+}
396+
353397 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
354398 log := logging.FromContext(ctx)
355399
@@ -227,6 +227,14 @@ func composeOnLoadChanged(
227 // For providers using postMessage (like Google Sign-In), we rely on the provider calling227 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
228 // window.close() which triggers WebKit's close signal.228 // window.close() which triggers WebKit's close signal.
229 // A long safety timeout (30s) catches popups that get stuck.229 // A long safety timeout (30s) catches popups that get stuck.
230+func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
231+ if wv == nil {
232+ return false
233+ }
234+ state, ok := wv.(port.PopupOpenerBridgeStateCapable)
235+ return ok && state.HasActivePopupOpenerBridge()
236+}
237+
230 func (c *Coordinator) setupOAuthAutoClose(238 func (c *Coordinator) setupOAuthAutoClose(
231 ctx context.Context,239 ctx context.Context,
232 paneID entity.PaneID,240 paneID entity.PaneID,
@@ -287,8 +295,7 @@ func (c *Coordinator) setupOAuthAutoClose(
287 })295 })
288 }296 }
289 297
290- requestOAuthClose := func(uri string, reason string) {298+ requestOAuthClose := func(reason string) {
291- c.capturePopupOAuthState(popupID, uri)
292 cancelSafetyTimer()299 cancelSafetyTimer()
293 log.Info().300 log.Info().
294 Str("pane", string(paneID)).301 Str("pane", string(paneID)).
@@ -309,13 +316,32 @@ func (c *Coordinator) setupOAuthAutoClose(
309 316
310 // Start safety timer immediately.317 // Start safety timer immediately.
311 startSafetyTimer()318 startSafetyTimer()
319+ deferCloseOnNavigation := popupUsesSyntheticOpenerSignals(wv)
312 320
313 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.321 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314 oauthWV.AddNavigationCallback(func(uri string) {322 oauthWV.AddNavigationCallback(func(uri string) {
315- if ShouldAutoClose(uri) {323+ if !ShouldAutoClose(uri) {
316- requestOAuthClose(uri, "navigation")324+ return
325+ }
326+ c.capturePopupOAuthState(popupID, uri)
327+ if deferCloseOnNavigation {
328+ cancelSafetyTimer()
329+ return
317 }330 }
331+ requestOAuthClose("navigation")
318 })332 })
333+ if openerNavigations, ok := wv.(port.PopupOpenerNavigationCapable); ok {
334+ openerNavigations.AddOpenerNavigationCallback(func(uri string) {
335+ c.capturePopupOAuthState(popupID, uri)
336+ requestOAuthClose("opener-navigation")
337+ })
338+ }
339+ if openerMessages, ok := wv.(port.PopupOpenerMessageCapable); ok {
340+ openerMessages.AddOpenerMessageCallback(func() {
341+ c.capturePopupOAuthMessage(popupID)
342+ requestOAuthClose("opener-message")
343+ })
344+ }
319 345
320 // Cancel safety timer on any close path.346 // Cancel safety timer on any close path.
321 oauthWV.AddCloseCallback(func() {347 oauthWV.AddCloseCallback(func() {
@@ -350,6 +376,24 @@ func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string)
350 state.Error = IsOAuthError(uri)376 state.Error = IsOAuthError(uri)
351 }377 }
352 378
379+func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
380+ c.popupMu.Lock()
381+ defer c.popupMu.Unlock()
382+
383+ state, ok := c.popupOAuth[popupID]
384+ if !ok {
385+ return
386+ }
387+
388+ state.Seen = true
389+ if state.CallbackURI == "" {
390+ state.CallbackURI = "postmessage://oauth-complete"
391+ }
392+ if !state.Error {
393+ state.Success = true
394+ }
395+}
396+
353 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {397 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
354 log := logging.FromContext(ctx)398 log := logging.FromContext(ctx)
355 399
M internal/ui/coordinator/content/oauth_test.go +35 -0
@@ -13,6 +13,23 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 )
1515
16+type popupOpenerBridgeStateStub struct {
17+ *portmocks.MockWebView
18+ active bool
19+}
20+
21+func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
22+
23+func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
24+ wv := &popupOpenerBridgeStateStub{MockWebView: portmocks.NewMockWebView(t), active: true}
25+ assert.True(t, popupUsesSyntheticOpenerSignals(wv))
26+}
27+
28+func TestPopupUsesSyntheticOpenerSignals_RejectsPlainWebView(t *testing.T) {
29+ wv := portmocks.NewMockWebView(t)
30+ assert.False(t, popupUsesSyntheticOpenerSignals(wv))
31+}
32+
1633 func TestComposeOnClose_Order(t *testing.T) {
1734 var calls []string
1835
@@ -49,6 +66,24 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
4966 assert.Equal(t, []string{"existing", "next"}, calls)
5067 }
5168
69+func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
70+ popupID := port.WebViewID(100)
71+ c := &Coordinator{
72+ popupOAuth: map[port.WebViewID]*popupOAuthState{
73+ popupID: {
74+ ParentPaneID: entity.PaneID("parent-pane"),
75+ },
76+ },
77+ }
78+
79+ c.capturePopupOAuthMessage(popupID)
80+
81+ state := c.popupOAuth[popupID]
82+ assert.True(t, state.Seen)
83+ assert.True(t, state.Success)
84+ assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
85+}
86+
5287 func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
5388 parentPaneID := entity.PaneID("parent-pane")
5489 popupID := port.WebViewID(101)
@@ -13,6 +13,23 @@ import (
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14 )14 )
15 15
16+type popupOpenerBridgeStateStub struct {
17+ *portmocks.MockWebView
18+ active bool
19+}
20+
21+func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
22+
23+func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
24+ wv := &popupOpenerBridgeStateStub{MockWebView: portmocks.NewMockWebView(t), active: true}
25+ assert.True(t, popupUsesSyntheticOpenerSignals(wv))
26+}
27+
28+func TestPopupUsesSyntheticOpenerSignals_RejectsPlainWebView(t *testing.T) {
29+ wv := portmocks.NewMockWebView(t)
30+ assert.False(t, popupUsesSyntheticOpenerSignals(wv))
31+}
32+
16 func TestComposeOnClose_Order(t *testing.T) {33 func TestComposeOnClose_Order(t *testing.T) {
17 var calls []string34 var calls []string
18 35
@@ -49,6 +66,24 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
49 assert.Equal(t, []string{"existing", "next"}, calls)66 assert.Equal(t, []string{"existing", "next"}, calls)
50 }67 }
51 68
69+func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
70+ popupID := port.WebViewID(100)
71+ c := &Coordinator{
72+ popupOAuth: map[port.WebViewID]*popupOAuthState{
73+ popupID: {
74+ ParentPaneID: entity.PaneID("parent-pane"),
75+ },
76+ },
77+ }
78+
79+ c.capturePopupOAuthMessage(popupID)
80+
81+ state := c.popupOAuth[popupID]
82+ assert.True(t, state.Seen)
83+ assert.True(t, state.Success)
84+ assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
85+}
86+
52 func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {87 func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
53 parentPaneID := entity.PaneID("parent-pane")88 parentPaneID := entity.PaneID("parent-pane")
54 popupID := port.WebViewID(101)89 popupID := port.WebViewID(101)
M internal/ui/coordinator/content/popup.go +54 -72
@@ -4,12 +4,11 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- neturl "net/url"
8- "strings"
97 "time"
108
119 "github.com/bnema/dumber/internal/application/port"
1210 "github.com/bnema/dumber/internal/domain/entity"
11+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
1312 "github.com/bnema/dumber/internal/logging"
1413 )
1514
@@ -275,40 +274,6 @@ func (c *Coordinator) createPopupPane(
275274 return paneID, popupPane
276275 }
277276
278-func normalizePopupTargetURIForFallback(rawTargetURI string) string {
279- trimmed := strings.TrimSpace(rawTargetURI)
280- if trimmed == "" {
281- return rawTargetURI
282- }
283-
284- parsed, err := neturl.Parse(trimmed)
285- if err != nil {
286- return rawTargetURI
287- }
288-
289- host := strings.ToLower(parsed.Hostname())
290- isNotionHost := host == "notion.so" || host == "notion.com" ||
291- strings.HasSuffix(host, ".notion.so") || strings.HasSuffix(host, ".notion.com")
292- if !isNotionHost || parsed.Path != "/verifyNoPopupBlockerHtmlAndRedirect" {
293- return rawTargetURI
294- }
295-
296- redirectURI := strings.TrimSpace(parsed.Query().Get("redirectUri"))
297- if redirectURI == "" {
298- return rawTargetURI
299- }
300-
301- redirectParsed, err := neturl.Parse(redirectURI)
302- if err != nil {
303- return rawTargetURI
304- }
305- if redirectParsed.Scheme != "https" && redirectParsed.Scheme != "http" {
306- return rawTargetURI
307- }
308-
309- return redirectParsed.String()
310-}
311-
312277 func (c *Coordinator) relatedPopupSupportDisabled() bool {
313278 c.popupMu.RLock()
314279 defer c.popupMu.RUnlock()
@@ -338,9 +303,9 @@ func (c *Coordinator) createPopupWebView(
338303 parentID port.WebViewID,
339304 targetURI string,
340305 noJavaScriptAccess bool,
341-) (port.WebView, string, error) {
306+) (port.WebView, string, bool, error) {
342307 if c.factory == nil {
343- return nil, targetURI, fmt.Errorf("no webview factory configured")
308+ return nil, targetURI, false, fmt.Errorf("no webview factory configured")
344309 }
345310
346311 log := logging.FromContext(ctx)
@@ -350,21 +315,30 @@ func (c *Coordinator) createPopupWebView(
350315 popupWV, err := c.factory.CreateRelated(ctx, parentID)
351316 if err == nil && popupWV != nil {
352317 c.markRelatedPopupSupported()
353- return popupWV, targetURI, nil
318+ return popupWV, targetURI, false, nil
354319 }
355320 if err == nil {
356- err = fmt.Errorf("related popup webview factory returned nil without error")
357- }
358- relatedErr = err
359- if errors.Is(err, port.ErrRelatedWebViewUnsupported) {
321+ relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
322+ log.Warn().
323+ Uint64("parent_webview_id", uint64(parentID)).
324+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
325+ Msg("related popup webview factory returned nil popup, falling back to regular webview")
326+ } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
327+ relatedErr = err
360328 c.markRelatedPopupUnsupported()
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+ } else {
335+ relatedErr = err
336+ log.Warn().
337+ Err(err).
338+ Uint64("parent_webview_id", uint64(parentID)).
339+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
340+ Msg("related popup webview creation failed, falling back to regular webview")
361341 }
362-
363- log.Debug().
364- Err(err).
365- Uint64("parent_webview_id", uint64(parentID)).
366- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
367- Msg("related popup webview unavailable, falling back to regular webview")
368342 } else if noJavaScriptAccess {
369343 log.Debug().
370344 Uint64("parent_webview_id", uint64(parentID)).
@@ -380,23 +354,15 @@ func (c *Coordinator) createPopupWebView(
380354 popupWV, fallbackErr := c.factory.Create(ctx)
381355 if fallbackErr != nil {
382356 if relatedErr != nil {
383- return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
357+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
384358 }
385- return nil, targetURI, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
359+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
386360 }
387361 if popupWV == nil {
388- return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")
389- }
390-
391- effectiveTargetURI := normalizePopupTargetURIForFallback(targetURI)
392- if effectiveTargetURI != targetURI {
393- log.Debug().
394- Str("original_target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
395- Str("effective_target_uri", logging.TruncateURL(effectiveTargetURI, logURLMaxLen)).
396- Msg("rewrote popup target URI for regular-webview fallback")
362+ return nil, targetURI, false, fmt.Errorf("popup webview factory returned nil without error")
397363 }
398364
399- return popupWV, effectiveTargetURI, nil
365+ return popupWV, targetURI, true, nil
400366 }
401367
402368 // handlePopupCreate handles a popup request from the current WebView.
@@ -435,8 +401,15 @@ func (c *Coordinator) handlePopupCreate(
435401
436402 parentID := parentWV.ID()
437403 parentURIAtOpen := ""
438- if parent := c.getWebViewLocked(parentPaneID); parent != nil {
439- parentURIAtOpen = parent.URI()
404+ if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
405+ if parentWV != nil {
406+ parentURIAtOpen = parentWV.URI()
407+ }
408+ if parentURIAtOpen == "" {
409+ if parent := c.getWebViewLocked(parentPaneID); parent != nil {
410+ parentURIAtOpen = parent.URI()
411+ }
412+ }
440413 }
441414 if !req.NoJavaScriptAccess {
442415 if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
@@ -445,9 +418,9 @@ func (c *Coordinator) handlePopupCreate(
445418 }
446419
447420 // Prefer a related WebView for session sharing, but fall back to a regular
448- // WebView on engines like CEF OSR that do not implement native related
449- // popup views yet.
450- popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
421+ // WebView when the active engine cannot provide a related popup instance for
422+ // this request.
423+ popupWV, effectiveTargetURI, usedRegularFallback, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
451424 if err != nil {
452425 log.Error().Err(err).Msg("failed to create webview for popup")
453426 return nil
@@ -456,6 +429,11 @@ func (c *Coordinator) handlePopupCreate(
456429 if effectiveTargetURI != req.TargetURI {
457430 req.TargetURI = effectiveTargetURI
458431 }
432+ if usedRegularFallback {
433+ if openerBridge, ok := popupWV.(port.PopupOpenerBridgeCapable); ok {
434+ openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
435+ }
436+ }
459437
460438 // Detect popup type
461439 popupType := DetectPopupType(req.FrameName)
@@ -578,6 +556,10 @@ func (c *Coordinator) finishPopupCreate(
578556 }
579557 inserted = true
580558
559+ if primable, ok := popupWV.(port.PopupNavigationCapable); ok {
560+ primable.PrimePopupNavigation(req.TargetURI)
561+ }
562+
581563 if !req.NoJavaScriptAccess {
582564 c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
583565 }
@@ -633,9 +615,8 @@ func (c *Coordinator) finishPopupCreate(
633615 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
634616 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
635617 } else {
636- // Engine does not support PopupCapable (e.g. CEF OSR where we create
637- // an independent browser, not a real CEF popup). The WebView is ready
638- // immediately — fire ready-to-show inline.
618+ // Engine does not expose popup lifecycle callbacks. Treat the inserted
619+ // WebView as ready immediately and continue with the same coordinator flow.
639620 log.Info().
640621 Uint64("popup_id", uint64(popupID)).
641622 Str("pane_id", string(paneID)).
@@ -677,9 +658,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
677658 if pc, ok := pending.WebView.(port.PopupCapable); ok {
678659 pc.Show()
679660 }
680- // For engines that create independent browsers (not real popups),
681- // the WebView has no pending navigation — load the target URI.
682- if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
661+ _, preloadsNavigation := pending.WebView.(port.PopupNavigationCapable)
662+ // Engines that do not preload popup navigation can request the target URI
663+ // here once the popup becomes visible.
664+ if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
683665 if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
684666 log.Warn().Err(err).
685667 Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
@@ -4,12 +4,11 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- neturl "net/url"
8- "strings"
9 "time"7 "time"
10 8
11 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
13 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
14 )13 )
15 14
@@ -275,40 +274,6 @@ func (c *Coordinator) createPopupPane(
275 return paneID, popupPane274 return paneID, popupPane
276 }275 }
277 276
278-func normalizePopupTargetURIForFallback(rawTargetURI string) string {
279- trimmed := strings.TrimSpace(rawTargetURI)
280- if trimmed == "" {
281- return rawTargetURI
282- }
283-
284- parsed, err := neturl.Parse(trimmed)
285- if err != nil {
286- return rawTargetURI
287- }
288-
289- host := strings.ToLower(parsed.Hostname())
290- isNotionHost := host == "notion.so" || host == "notion.com" ||
291- strings.HasSuffix(host, ".notion.so") || strings.HasSuffix(host, ".notion.com")
292- if !isNotionHost || parsed.Path != "/verifyNoPopupBlockerHtmlAndRedirect" {
293- return rawTargetURI
294- }
295-
296- redirectURI := strings.TrimSpace(parsed.Query().Get("redirectUri"))
297- if redirectURI == "" {
298- return rawTargetURI
299- }
300-
301- redirectParsed, err := neturl.Parse(redirectURI)
302- if err != nil {
303- return rawTargetURI
304- }
305- if redirectParsed.Scheme != "https" && redirectParsed.Scheme != "http" {
306- return rawTargetURI
307- }
308-
309- return redirectParsed.String()
310-}
311-
312 func (c *Coordinator) relatedPopupSupportDisabled() bool {277 func (c *Coordinator) relatedPopupSupportDisabled() bool {
313 c.popupMu.RLock()278 c.popupMu.RLock()
314 defer c.popupMu.RUnlock()279 defer c.popupMu.RUnlock()
@@ -338,9 +303,9 @@ func (c *Coordinator) createPopupWebView(
338 parentID port.WebViewID,303 parentID port.WebViewID,
339 targetURI string,304 targetURI string,
340 noJavaScriptAccess bool,305 noJavaScriptAccess bool,
341-) (port.WebView, string, error) {306+) (port.WebView, string, bool, error) {
342 if c.factory == nil {307 if c.factory == nil {
343- return nil, targetURI, fmt.Errorf("no webview factory configured")308+ return nil, targetURI, false, fmt.Errorf("no webview factory configured")
344 }309 }
345 310
346 log := logging.FromContext(ctx)311 log := logging.FromContext(ctx)
@@ -350,21 +315,30 @@ func (c *Coordinator) createPopupWebView(
350 popupWV, err := c.factory.CreateRelated(ctx, parentID)315 popupWV, err := c.factory.CreateRelated(ctx, parentID)
351 if err == nil && popupWV != nil {316 if err == nil && popupWV != nil {
352 c.markRelatedPopupSupported()317 c.markRelatedPopupSupported()
353- return popupWV, targetURI, nil318+ return popupWV, targetURI, false, nil
354 }319 }
355 if err == nil {320 if err == nil {
356- err = fmt.Errorf("related popup webview factory returned nil without error")321+ relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
357- }322+ log.Warn().
358- relatedErr = err323+ Uint64("parent_webview_id", uint64(parentID)).
359- if errors.Is(err, port.ErrRelatedWebViewUnsupported) {324+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
325+ Msg("related popup webview factory returned nil popup, falling back to regular webview")
326+ } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
327+ relatedErr = err
360 c.markRelatedPopupUnsupported()328 c.markRelatedPopupUnsupported()
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+ } else {
335+ relatedErr = err
336+ log.Warn().
337+ Err(err).
338+ Uint64("parent_webview_id", uint64(parentID)).
339+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
340+ Msg("related popup webview creation failed, falling back to regular webview")
361 }341 }
362-
363- log.Debug().
364- Err(err).
365- Uint64("parent_webview_id", uint64(parentID)).
366- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
367- Msg("related popup webview unavailable, falling back to regular webview")
368 } else if noJavaScriptAccess {342 } else if noJavaScriptAccess {
369 log.Debug().343 log.Debug().
370 Uint64("parent_webview_id", uint64(parentID)).344 Uint64("parent_webview_id", uint64(parentID)).
@@ -380,23 +354,15 @@ func (c *Coordinator) createPopupWebView(
380 popupWV, fallbackErr := c.factory.Create(ctx)354 popupWV, fallbackErr := c.factory.Create(ctx)
381 if fallbackErr != nil {355 if fallbackErr != nil {
382 if relatedErr != nil {356 if relatedErr != nil {
383- return nil, targetURI, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)357+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
384 }358 }
385- return nil, targetURI, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)359+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
386 }360 }
387 if popupWV == nil {361 if popupWV == nil {
388- return nil, targetURI, fmt.Errorf("popup webview factory returned nil without error")362+ return nil, targetURI, false, fmt.Errorf("popup webview factory returned nil without error")
389- }
390-
391- effectiveTargetURI := normalizePopupTargetURIForFallback(targetURI)
392- if effectiveTargetURI != targetURI {
393- log.Debug().
394- Str("original_target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
395- Str("effective_target_uri", logging.TruncateURL(effectiveTargetURI, logURLMaxLen)).
396- Msg("rewrote popup target URI for regular-webview fallback")
397 }363 }
398 364
399- return popupWV, effectiveTargetURI, nil365+ return popupWV, targetURI, true, nil
400 }366 }
401 367
402 // handlePopupCreate handles a popup request from the current WebView.368 // handlePopupCreate handles a popup request from the current WebView.
@@ -435,8 +401,15 @@ func (c *Coordinator) handlePopupCreate(
435 401
436 parentID := parentWV.ID()402 parentID := parentWV.ID()
437 parentURIAtOpen := ""403 parentURIAtOpen := ""
438- if parent := c.getWebViewLocked(parentPaneID); parent != nil {404+ if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
439- parentURIAtOpen = parent.URI()405+ if parentWV != nil {
406+ parentURIAtOpen = parentWV.URI()
407+ }
408+ if parentURIAtOpen == "" {
409+ if parent := c.getWebViewLocked(parentPaneID); parent != nil {
410+ parentURIAtOpen = parent.URI()
411+ }
412+ }
440 }413 }
441 if !req.NoJavaScriptAccess {414 if !req.NoJavaScriptAccess {
442 if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {415 if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
@@ -445,9 +418,9 @@ func (c *Coordinator) handlePopupCreate(
445 }418 }
446 419
447 // Prefer a related WebView for session sharing, but fall back to a regular420 // Prefer a related WebView for session sharing, but fall back to a regular
448- // WebView on engines like CEF OSR that do not implement native related421+ // WebView when the active engine cannot provide a related popup instance for
449- // popup views yet.422+ // this request.
450- popupWV, effectiveTargetURI, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)423+ popupWV, effectiveTargetURI, usedRegularFallback, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
451 if err != nil {424 if err != nil {
452 log.Error().Err(err).Msg("failed to create webview for popup")425 log.Error().Err(err).Msg("failed to create webview for popup")
453 return nil426 return nil
@@ -456,6 +429,11 @@ func (c *Coordinator) handlePopupCreate(
456 if effectiveTargetURI != req.TargetURI {429 if effectiveTargetURI != req.TargetURI {
457 req.TargetURI = effectiveTargetURI430 req.TargetURI = effectiveTargetURI
458 }431 }
432+ if usedRegularFallback {
433+ if openerBridge, ok := popupWV.(port.PopupOpenerBridgeCapable); ok {
434+ openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
435+ }
436+ }
459 437
460 // Detect popup type438 // Detect popup type
461 popupType := DetectPopupType(req.FrameName)439 popupType := DetectPopupType(req.FrameName)
@@ -578,6 +556,10 @@ func (c *Coordinator) finishPopupCreate(
578 }556 }
579 inserted = true557 inserted = true
580 558
559+ if primable, ok := popupWV.(port.PopupNavigationCapable); ok {
560+ primable.PrimePopupNavigation(req.TargetURI)
561+ }
562+
581 if !req.NoJavaScriptAccess {563 if !req.NoJavaScriptAccess {
582 c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)564 c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
583 }565 }
@@ -633,9 +615,8 @@ func (c *Coordinator) finishPopupCreate(
633 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).615 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
634 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")616 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
635 } else {617 } else {
636- // Engine does not support PopupCapable (e.g. CEF OSR where we create618+ // Engine does not expose popup lifecycle callbacks. Treat the inserted
637- // an independent browser, not a real CEF popup). The WebView is ready619+ // WebView as ready immediately and continue with the same coordinator flow.
638- // immediately — fire ready-to-show inline.
639 log.Info().620 log.Info().
640 Uint64("popup_id", uint64(popupID)).621 Uint64("popup_id", uint64(popupID)).
641 Str("pane_id", string(paneID)).622 Str("pane_id", string(paneID)).
@@ -677,9 +658,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
677 if pc, ok := pending.WebView.(port.PopupCapable); ok {658 if pc, ok := pending.WebView.(port.PopupCapable); ok {
678 pc.Show()659 pc.Show()
679 }660 }
680- // For engines that create independent browsers (not real popups),661+ _, preloadsNavigation := pending.WebView.(port.PopupNavigationCapable)
681- // the WebView has no pending navigation — load the target URI.662+ // Engines that do not preload popup navigation can request the target URI
682- if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {663+ // here once the popup becomes visible.
664+ if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
683 if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {665 if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
684 log.Warn().Err(err).666 log.Warn().Err(err).
685 Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).667 Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
M internal/ui/coordinator/content/popup_test.go +108 -26
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/application/port"
1414 "github.com/bnema/dumber/internal/application/port/mocks"
1515 "github.com/bnema/dumber/internal/domain/entity"
16+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
1617 )
1718
1819 // ---------------------------------------------------------------------------
@@ -126,23 +127,6 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
126127 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
127128 }
128129
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-
146130 func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
147131 t.Helper()
148132
@@ -167,6 +151,41 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
167151 return ctx, parentPaneID, parentWV, popupWV, c
168152 }
169153
154+func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
155+ ctx := context.Background()
156+ parentPaneID := entity.PaneID("parent-pane")
157+ parentWV := mocks.NewMockWebView(t)
158+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159+
160+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
161+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()
162+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
163+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
164+
165+ factory := mocks.NewMockWebViewFactory(t)
166+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
167+
168+ c := &Coordinator{
169+ webViews: make(map[entity.PaneID]port.WebView),
170+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
171+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
172+ }
173+ c.SetPopupConfig(factory, nil, nil)
174+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
175+ return nil
176+ })
177+
178+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
179+ TargetURI: "https://example.com/popup",
180+ FrameName: "auth-popup",
181+ IsUserGesture: true,
182+ NoJavaScriptAccess: true,
183+ })
184+
185+ require.Same(t, popupWV, created)
186+ require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
187+}
188+
170189 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
171190 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
172191 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
@@ -274,7 +293,7 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
274293 secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
275294
276295 factory := mocks.NewMockWebViewFactory(t)
277- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, port.ErrRelatedWebViewUnsupported).Once()
296+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
278297 factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
279298 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
280299
@@ -303,7 +322,7 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
303322 require.Same(t, secondPopupWV, second)
304323 }
305324
306-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
325+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupported(t *testing.T) {
307326 ctx := context.Background()
308327 parentPaneID := entity.PaneID("parent-pane")
309328 parentWV := mocks.NewMockWebView(t)
@@ -318,7 +337,7 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *te
318337 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
319338
320339 factory := mocks.NewMockWebViewFactory(t)
321- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
340+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
322341 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
323342
324343 insertCalls := 0
@@ -346,22 +365,63 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *te
346365 assert.Equal(t, 1, insertCalls)
347366 }
348367
349-func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView(t *testing.T) {
368+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
350369 ctx := context.Background()
351370 parentPaneID := entity.PaneID("parent-pane")
352371 parentWV := mocks.NewMockWebView(t)
353372 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
354373
355374 popupWV := mocks.NewMockWebView(t)
375+ popupWV.EXPECT().ID().Return(port.WebViewID(204)).Once()
376+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
377+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
378+ popupWV.EXPECT().IsLoading().Return(false).Once()
379+ popupWV.EXPECT().URI().Return("").Once()
380+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
381+
382+ factory := mocks.NewMockWebViewFactory(t)
383+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
384+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
385+
386+ insertCalls := 0
387+ c := &Coordinator{
388+ webViews: make(map[entity.PaneID]port.WebView),
389+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
390+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
391+ }
392+ c.SetPopupConfig(factory, nil, nil)
393+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
394+ insertCalls++
395+ assert.Equal(t, popupWV, input.WebView)
396+ return nil
397+ })
398+
399+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
400+ TargetURI: "https://example.com/edit",
401+ FrameName: "_blank",
402+ IsUserGesture: true,
403+ })
404+
405+ require.Same(t, popupWV, created)
406+ assert.Equal(t, 1, insertCalls)
407+}
408+
409+func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported(t *testing.T) {
410+ ctx := context.Background()
411+ parentPaneID := entity.PaneID("parent-pane")
412+ parentWV := mocks.NewMockWebView(t)
413+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
414+
415+ popupWV := &popupOpenerBridgeWebViewStub{MockWebView: mocks.NewMockWebView(t)}
356416 popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
357417 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
358418 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
359419 popupWV.EXPECT().IsLoading().Return(false).Once()
360420 popupWV.EXPECT().URI().Return("").Once()
361- popupWV.EXPECT().LoadURI(mock.Anything, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc").Return(nil).Once()
421+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup?redirect=%2Fcallback").Return(nil).Once()
362422
363423 factory := mocks.NewMockWebViewFactory(t)
364- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()
424+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
365425 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
366426
367427 insertCalls := 0
@@ -373,18 +433,20 @@ func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView
373433 c.SetPopupConfig(factory, nil, nil)
374434 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
375435 insertCalls++
376- assert.Equal(t, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc", input.TargetURI)
436+ assert.Equal(t, "https://example.com/popup?redirect=%2Fcallback", input.TargetURI)
377437 return nil
378438 })
379439
380440 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
381- TargetURI: "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc",
382- FrameName: "Google login",
441+ TargetURI: "https://example.com/popup?redirect=%2Fcallback",
442+ FrameName: "auth-popup",
383443 IsUserGesture: true,
384444 })
385445
386446 require.Same(t, popupWV, created)
387447 assert.Equal(t, 1, insertCalls)
448+ assert.Same(t, parentWV, popupWV.parent)
449+ assert.False(t, popupWV.noJavaScriptAccess)
388450 }
389451
390452 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
@@ -566,6 +628,26 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
566628 assert.Nil(t, c.generateID)
567629 }
568630
631+type popupNavigationWebViewStub struct {
632+ *mocks.MockWebView
633+ primed []string
634+}
635+
636+func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
637+ s.primed = append(s.primed, uri)
638+}
639+
640+type popupOpenerBridgeWebViewStub struct {
641+ *mocks.MockWebView
642+ parent port.WebView
643+ noJavaScriptAccess bool
644+}
645+
646+func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
647+ s.parent = parent
648+ s.noJavaScriptAccess = noJavaScriptAccess
649+}
650+
569651 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
570652 type stubFactory struct{}
571653
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/port/mocks"14 "github.com/bnema/dumber/internal/application/port/mocks"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
16 )17 )
17 18
18 // ---------------------------------------------------------------------------19 // ---------------------------------------------------------------------------
@@ -126,23 +127,6 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
126 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))127 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
127 }128 }
128 129
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 newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {130 func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
147 t.Helper()131 t.Helper()
148 132
@@ -167,6 +151,41 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
167 return ctx, parentPaneID, parentWV, popupWV, c151 return ctx, parentPaneID, parentWV, popupWV, c
168 }152 }
169 153
154+func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
155+ ctx := context.Background()
156+ parentPaneID := entity.PaneID("parent-pane")
157+ parentWV := mocks.NewMockWebView(t)
158+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159+
160+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
161+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()
162+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
163+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
164+
165+ factory := mocks.NewMockWebViewFactory(t)
166+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
167+
168+ c := &Coordinator{
169+ webViews: make(map[entity.PaneID]port.WebView),
170+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
171+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
172+ }
173+ c.SetPopupConfig(factory, nil, nil)
174+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
175+ return nil
176+ })
177+
178+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
179+ TargetURI: "https://example.com/popup",
180+ FrameName: "auth-popup",
181+ IsUserGesture: true,
182+ NoJavaScriptAccess: true,
183+ })
184+
185+ require.Same(t, popupWV, created)
186+ require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
187+}
188+
170 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {189 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
171 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))190 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
172 popupWV.EXPECT().Generation().Return(uint64(1)).Once()191 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
@@ -274,7 +293,7 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
274 secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()293 secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
275 294
276 factory := mocks.NewMockWebViewFactory(t)295 factory := mocks.NewMockWebViewFactory(t)
277- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, port.ErrRelatedWebViewUnsupported).Once()296+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
278 factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()297 factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
279 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()298 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
280 299
@@ -303,7 +322,7 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
303 require.Same(t, secondPopupWV, second)322 require.Same(t, secondPopupWV, second)
304 }323 }
305 324
306-func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {325+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupported(t *testing.T) {
307 ctx := context.Background()326 ctx := context.Background()
308 parentPaneID := entity.PaneID("parent-pane")327 parentPaneID := entity.PaneID("parent-pane")
309 parentWV := mocks.NewMockWebView(t)328 parentWV := mocks.NewMockWebView(t)
@@ -318,7 +337,7 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *te
318 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()337 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
319 338
320 factory := mocks.NewMockWebViewFactory(t)339 factory := mocks.NewMockWebViewFactory(t)
321- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()340+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
322 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()341 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
323 342
324 insertCalls := 0343 insertCalls := 0
@@ -346,22 +365,63 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *te
346 assert.Equal(t, 1, insertCalls)365 assert.Equal(t, 1, insertCalls)
347 }366 }
348 367
349-func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView(t *testing.T) {368+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
350 ctx := context.Background()369 ctx := context.Background()
351 parentPaneID := entity.PaneID("parent-pane")370 parentPaneID := entity.PaneID("parent-pane")
352 parentWV := mocks.NewMockWebView(t)371 parentWV := mocks.NewMockWebView(t)
353 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()372 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
354 373
355 popupWV := mocks.NewMockWebView(t)374 popupWV := mocks.NewMockWebView(t)
375+ popupWV.EXPECT().ID().Return(port.WebViewID(204)).Once()
376+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
377+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
378+ popupWV.EXPECT().IsLoading().Return(false).Once()
379+ popupWV.EXPECT().URI().Return("").Once()
380+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
381+
382+ factory := mocks.NewMockWebViewFactory(t)
383+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
384+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
385+
386+ insertCalls := 0
387+ c := &Coordinator{
388+ webViews: make(map[entity.PaneID]port.WebView),
389+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
390+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
391+ }
392+ c.SetPopupConfig(factory, nil, nil)
393+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
394+ insertCalls++
395+ assert.Equal(t, popupWV, input.WebView)
396+ return nil
397+ })
398+
399+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
400+ TargetURI: "https://example.com/edit",
401+ FrameName: "_blank",
402+ IsUserGesture: true,
403+ })
404+
405+ require.Same(t, popupWV, created)
406+ assert.Equal(t, 1, insertCalls)
407+}
408+
409+func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported(t *testing.T) {
410+ ctx := context.Background()
411+ parentPaneID := entity.PaneID("parent-pane")
412+ parentWV := mocks.NewMockWebView(t)
413+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
414+
415+ popupWV := &popupOpenerBridgeWebViewStub{MockWebView: mocks.NewMockWebView(t)}
356 popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()416 popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
357 popupWV.EXPECT().Generation().Return(uint64(1)).Once()417 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
358 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()418 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
359 popupWV.EXPECT().IsLoading().Return(false).Once()419 popupWV.EXPECT().IsLoading().Return(false).Once()
360 popupWV.EXPECT().URI().Return("").Once()420 popupWV.EXPECT().URI().Return("").Once()
361- popupWV.EXPECT().LoadURI(mock.Anything, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc").Return(nil).Once()421+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup?redirect=%2Fcallback").Return(nil).Once()
362 422
363 factory := mocks.NewMockWebViewFactory(t)423 factory := mocks.NewMockWebViewFactory(t)
364- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("related popup webviews are not supported yet")).Once()424+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
365 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()425 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
366 426
367 insertCalls := 0427 insertCalls := 0
@@ -373,18 +433,20 @@ func TestHandlePopupCreate_RewritesNotionVerifierWhenFallingBackToRegularWebView
373 c.SetPopupConfig(factory, nil, nil)433 c.SetPopupConfig(factory, nil, nil)
374 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {434 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
375 insertCalls++435 insertCalls++
376- assert.Equal(t, "https://www.notion.so/googlepopupredirect?callbackType=popup&redirectToAuth=true&requestId=abc", input.TargetURI)436+ assert.Equal(t, "https://example.com/popup?redirect=%2Fcallback", input.TargetURI)
377 return nil437 return nil
378 })438 })
379 439
380 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{440 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
381- TargetURI: "https://www.notion.so/verifyNoPopupBlockerHtmlAndRedirect?redirectUri=https%3A%2F%2Fwww.notion.so%2Fgooglepopupredirect%3FcallbackType%3Dpopup%26redirectToAuth%3Dtrue%26requestId%3Dabc",441+ TargetURI: "https://example.com/popup?redirect=%2Fcallback",
382- FrameName: "Google login",442+ FrameName: "auth-popup",
383 IsUserGesture: true,443 IsUserGesture: true,
384 })444 })
385 445
386 require.Same(t, popupWV, created)446 require.Same(t, popupWV, created)
387 assert.Equal(t, 1, insertCalls)447 assert.Equal(t, 1, insertCalls)
448+ assert.Same(t, parentWV, popupWV.parent)
449+ assert.False(t, popupWV.noJavaScriptAccess)
388 }450 }
389 451
390 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {452 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
@@ -566,6 +628,26 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
566 assert.Nil(t, c.generateID)628 assert.Nil(t, c.generateID)
567 }629 }
568 630
631+type popupNavigationWebViewStub struct {
632+ *mocks.MockWebView
633+ primed []string
634+}
635+
636+func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
637+ s.primed = append(s.primed, uri)
638+}
639+
640+type popupOpenerBridgeWebViewStub struct {
641+ *mocks.MockWebView
642+ parent port.WebView
643+ noJavaScriptAccess bool
644+}
645+
646+func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
647+ s.parent = parent
648+ s.noJavaScriptAccess = noJavaScriptAccess
649+}
650+
569 // stubFactory satisfies port.WebViewFactory without importing the mocks package.651 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
570 type stubFactory struct{}652 type stubFactory struct{}
571 653

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