detached 716a5ee

Merge pull request #231 from bnema/fix/cef-popup-pane-handling

fix: improve CEF popup handling and OAuth reliability
commit
716a5eec36eafd6681da605ebab97417d9847495
author
bnema · · 2026-04-23 18:02 UTC
committer
GitHub · · 2026-04-23 18:02 UTC
merge of
560871d + 5cc8ec3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
35 files · +5854 -1378
M .mockery.yaml +2 -1
@@ -42,7 +42,8 @@ packages:
4242 FilterApplier: {}
4343 FaviconDatabase: {}
4444 IdleInhibitor: {}
45- PopupCapable: {}
45+ PopupLifecycleCapable: {}
46+ PopupOpenerCapable: {}
4647 OAuthCallbackCapable: {}
4748 DevToolsOpener: {}
4849 Printer: {}
M internal/application/port/mocks/mocks.go +273 -36
@@ -14273,13 +14273,13 @@ func (_c *MockPrinter_PrintPage_Call) RunAndReturn(run func()) *MockPrinter_Prin
1427314273 return _c
1427414274 }
1427514275
14276-// NewMockPopupCapable creates a new instance of MockPopupCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14276+// NewMockPopupLifecycleCapable creates a new instance of MockPopupLifecycleCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1427714277 // The first argument is typically a *testing.T value.
14278-func NewMockPopupCapable(t interface {
14278+func NewMockPopupLifecycleCapable(t interface {
1427914279 mock.TestingT
1428014280 Cleanup(func())
14281-}) *MockPopupCapable {
14282- mock := &MockPopupCapable{}
14281+}) *MockPopupLifecycleCapable {
14282+ mock := &MockPopupLifecycleCapable{}
1428314283 mock.Mock.Test(t)
1428414284
1428514285 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -14287,37 +14287,77 @@ func NewMockPopupCapable(t interface {
1428714287 return mock
1428814288 }
1428914289
14290-// MockPopupCapable is an autogenerated mock type for the PopupCapable type
14291-type MockPopupCapable struct {
14290+// MockPopupLifecycleCapable is an autogenerated mock type for the PopupLifecycleCapable type
14291+type MockPopupLifecycleCapable struct {
1429214292 mock.Mock
1429314293 }
1429414294
14295-type MockPopupCapable_Expecter struct {
14295+type MockPopupLifecycleCapable_Expecter struct {
1429614296 mock *mock.Mock
1429714297 }
1429814298
14299-func (_m *MockPopupCapable) EXPECT() *MockPopupCapable_Expecter {
14300- return &MockPopupCapable_Expecter{mock: &_m.Mock}
14299+func (_m *MockPopupLifecycleCapable) EXPECT() *MockPopupLifecycleCapable_Expecter {
14300+ return &MockPopupLifecycleCapable_Expecter{mock: &_m.Mock}
1430114301 }
1430214302
14303-// SetOnClose provides a mock function for the type MockPopupCapable
14304-func (_mock *MockPopupCapable) SetOnClose(fn func()) {
14303+// PrimePopupNavigation provides a mock function for the type MockPopupLifecycleCapable
14304+func (_mock *MockPopupLifecycleCapable) PrimePopupNavigation(uri string) {
14305+ _mock.Called(uri)
14306+ return
14307+}
14308+
14309+// MockPopupLifecycleCapable_PrimePopupNavigation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrimePopupNavigation'
14310+type MockPopupLifecycleCapable_PrimePopupNavigation_Call struct {
14311+ *mock.Call
14312+}
14313+
14314+// PrimePopupNavigation is a helper method to define mock.On call
14315+// - uri string
14316+func (_e *MockPopupLifecycleCapable_Expecter) PrimePopupNavigation(uri interface{}) *MockPopupLifecycleCapable_PrimePopupNavigation_Call {
14317+ return &MockPopupLifecycleCapable_PrimePopupNavigation_Call{Call: _e.mock.On("PrimePopupNavigation", uri)}
14318+}
14319+
14320+func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Run(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call {
14321+ _c.Call.Run(func(args mock.Arguments) {
14322+ var arg0 string
14323+ if args[0] != nil {
14324+ arg0 = args[0].(string)
14325+ }
14326+ run(
14327+ arg0,
14328+ )
14329+ })
14330+ return _c
14331+}
14332+
14333+func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Return() *MockPopupLifecycleCapable_PrimePopupNavigation_Call {
14334+ _c.Call.Return()
14335+ return _c
14336+}
14337+
14338+func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) RunAndReturn(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call {
14339+ _c.Run(run)
14340+ return _c
14341+}
14342+
14343+// SetOnClose provides a mock function for the type MockPopupLifecycleCapable
14344+func (_mock *MockPopupLifecycleCapable) SetOnClose(fn func()) {
1430514345 _mock.Called(fn)
1430614346 return
1430714347 }
1430814348
14309-// MockPopupCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose'
14310-type MockPopupCapable_SetOnClose_Call struct {
14349+// MockPopupLifecycleCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose'
14350+type MockPopupLifecycleCapable_SetOnClose_Call struct {
1431114351 *mock.Call
1431214352 }
1431314353
1431414354 // SetOnClose is a helper method to define mock.On call
1431514355 // - fn func()
14316-func (_e *MockPopupCapable_Expecter) SetOnClose(fn interface{}) *MockPopupCapable_SetOnClose_Call {
14317- return &MockPopupCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)}
14356+func (_e *MockPopupLifecycleCapable_Expecter) SetOnClose(fn interface{}) *MockPopupLifecycleCapable_SetOnClose_Call {
14357+ return &MockPopupLifecycleCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)}
1431814358 }
1431914359
14320-func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupCapable_SetOnClose_Call {
14360+func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupLifecycleCapable_SetOnClose_Call {
1432114361 _c.Call.Run(func(args mock.Arguments) {
1432214362 var arg0 func()
1432314363 if args[0] != nil {
@@ -14330,34 +14370,34 @@ func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupC
1433014370 return _c
1433114371 }
1433214372
14333-func (_c *MockPopupCapable_SetOnClose_Call) Return() *MockPopupCapable_SetOnClose_Call {
14373+func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Return() *MockPopupLifecycleCapable_SetOnClose_Call {
1433414374 _c.Call.Return()
1433514375 return _c
1433614376 }
1433714377
14338-func (_c *MockPopupCapable_SetOnClose_Call) RunAndReturn(run func(fn func())) *MockPopupCapable_SetOnClose_Call {
14378+func (_c *MockPopupLifecycleCapable_SetOnClose_Call) RunAndReturn(run func(fn func())) *MockPopupLifecycleCapable_SetOnClose_Call {
1433914379 _c.Run(run)
1434014380 return _c
1434114381 }
1434214382
14343-// SetOnReadyToShow provides a mock function for the type MockPopupCapable
14344-func (_mock *MockPopupCapable) SetOnReadyToShow(fn func()) {
14383+// SetOnReadyToShow provides a mock function for the type MockPopupLifecycleCapable
14384+func (_mock *MockPopupLifecycleCapable) SetOnReadyToShow(fn func()) {
1434514385 _mock.Called(fn)
1434614386 return
1434714387 }
1434814388
14349-// MockPopupCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow'
14350-type MockPopupCapable_SetOnReadyToShow_Call struct {
14389+// MockPopupLifecycleCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow'
14390+type MockPopupLifecycleCapable_SetOnReadyToShow_Call struct {
1435114391 *mock.Call
1435214392 }
1435314393
1435414394 // SetOnReadyToShow is a helper method to define mock.On call
1435514395 // - fn func()
14356-func (_e *MockPopupCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupCapable_SetOnReadyToShow_Call {
14357- return &MockPopupCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)}
14396+func (_e *MockPopupLifecycleCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
14397+ return &MockPopupLifecycleCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)}
1435814398 }
1435914399
14360-func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *MockPopupCapable_SetOnReadyToShow_Call {
14400+func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
1436114401 _c.Call.Run(func(args mock.Arguments) {
1436214402 var arg0 func()
1436314403 if args[0] != nil {
@@ -14370,49 +14410,246 @@ func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *Mock
1437014410 return _c
1437114411 }
1437214412
14373-func (_c *MockPopupCapable_SetOnReadyToShow_Call) Return() *MockPopupCapable_SetOnReadyToShow_Call {
14413+func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Return() *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
1437414414 _c.Call.Return()
1437514415 return _c
1437614416 }
1437714417
14378-func (_c *MockPopupCapable_SetOnReadyToShow_Call) RunAndReturn(run func(fn func())) *MockPopupCapable_SetOnReadyToShow_Call {
14418+func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) RunAndReturn(run func(fn func())) *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
1437914419 _c.Run(run)
1438014420 return _c
1438114421 }
1438214422
14383-// Show provides a mock function for the type MockPopupCapable
14384-func (_mock *MockPopupCapable) Show() {
14423+// Show provides a mock function for the type MockPopupLifecycleCapable
14424+func (_mock *MockPopupLifecycleCapable) Show() {
1438514425 _mock.Called()
1438614426 return
1438714427 }
1438814428
14389-// MockPopupCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
14390-type MockPopupCapable_Show_Call struct {
14429+// MockPopupLifecycleCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
14430+type MockPopupLifecycleCapable_Show_Call struct {
1439114431 *mock.Call
1439214432 }
1439314433
1439414434 // Show is a helper method to define mock.On call
14395-func (_e *MockPopupCapable_Expecter) Show() *MockPopupCapable_Show_Call {
14396- return &MockPopupCapable_Show_Call{Call: _e.mock.On("Show")}
14435+func (_e *MockPopupLifecycleCapable_Expecter) Show() *MockPopupLifecycleCapable_Show_Call {
14436+ return &MockPopupLifecycleCapable_Show_Call{Call: _e.mock.On("Show")}
1439714437 }
1439814438
14399-func (_c *MockPopupCapable_Show_Call) Run(run func()) *MockPopupCapable_Show_Call {
14439+func (_c *MockPopupLifecycleCapable_Show_Call) Run(run func()) *MockPopupLifecycleCapable_Show_Call {
1440014440 _c.Call.Run(func(args mock.Arguments) {
1440114441 run()
1440214442 })
1440314443 return _c
1440414444 }
1440514445
14406-func (_c *MockPopupCapable_Show_Call) Return() *MockPopupCapable_Show_Call {
14446+func (_c *MockPopupLifecycleCapable_Show_Call) Return() *MockPopupLifecycleCapable_Show_Call {
14447+ _c.Call.Return()
14448+ return _c
14449+}
14450+
14451+func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPopupLifecycleCapable_Show_Call {
14452+ _c.Run(run)
14453+ return _c
14454+}
14455+
14456+// NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14457+// The first argument is typically a *testing.T value.
14458+func NewMockPopupOpenerCapable(t interface {
14459+ mock.TestingT
14460+ Cleanup(func())
14461+}) *MockPopupOpenerCapable {
14462+ mock := &MockPopupOpenerCapable{}
14463+ mock.Mock.Test(t)
14464+
14465+ t.Cleanup(func() { mock.AssertExpectations(t) })
14466+
14467+ return mock
14468+}
14469+
14470+// MockPopupOpenerCapable is an autogenerated mock type for the PopupOpenerCapable type
14471+type MockPopupOpenerCapable struct {
14472+ mock.Mock
14473+}
14474+
14475+type MockPopupOpenerCapable_Expecter struct {
14476+ mock *mock.Mock
14477+}
14478+
14479+func (_m *MockPopupOpenerCapable) EXPECT() *MockPopupOpenerCapable_Expecter {
14480+ return &MockPopupOpenerCapable_Expecter{mock: &_m.Mock}
14481+}
14482+
14483+// AddOpenerMessageCallback provides a mock function for the type MockPopupOpenerCapable
14484+func (_mock *MockPopupOpenerCapable) AddOpenerMessageCallback(fn func()) {
14485+ _mock.Called(fn)
14486+ return
14487+}
14488+
14489+// MockPopupOpenerCapable_AddOpenerMessageCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOpenerMessageCallback'
14490+type MockPopupOpenerCapable_AddOpenerMessageCallback_Call struct {
14491+ *mock.Call
14492+}
14493+
14494+// AddOpenerMessageCallback is a helper method to define mock.On call
14495+// - fn func()
14496+func (_e *MockPopupOpenerCapable_Expecter) AddOpenerMessageCallback(fn interface{}) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call {
14497+ return &MockPopupOpenerCapable_AddOpenerMessageCallback_Call{Call: _e.mock.On("AddOpenerMessageCallback", fn)}
14498+}
14499+
14500+func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) Run(run func(fn func())) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call {
14501+ _c.Call.Run(func(args mock.Arguments) {
14502+ var arg0 func()
14503+ if args[0] != nil {
14504+ arg0 = args[0].(func())
14505+ }
14506+ run(
14507+ arg0,
14508+ )
14509+ })
14510+ return _c
14511+}
14512+
14513+func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) Return() *MockPopupOpenerCapable_AddOpenerMessageCallback_Call {
1440714514 _c.Call.Return()
1440814515 return _c
1440914516 }
1441014517
14411-func (_c *MockPopupCapable_Show_Call) RunAndReturn(run func()) *MockPopupCapable_Show_Call {
14518+func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) RunAndReturn(run func(fn func())) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call {
1441214519 _c.Run(run)
1441314520 return _c
1441414521 }
1441514522
14523+// AddOpenerNavigationCallback provides a mock function for the type MockPopupOpenerCapable
14524+func (_mock *MockPopupOpenerCapable) AddOpenerNavigationCallback(fn func(uri string)) {
14525+ _mock.Called(fn)
14526+ return
14527+}
14528+
14529+// MockPopupOpenerCapable_AddOpenerNavigationCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOpenerNavigationCallback'
14530+type MockPopupOpenerCapable_AddOpenerNavigationCallback_Call struct {
14531+ *mock.Call
14532+}
14533+
14534+// AddOpenerNavigationCallback is a helper method to define mock.On call
14535+// - fn func(uri string)
14536+func (_e *MockPopupOpenerCapable_Expecter) AddOpenerNavigationCallback(fn interface{}) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call {
14537+ return &MockPopupOpenerCapable_AddOpenerNavigationCallback_Call{Call: _e.mock.On("AddOpenerNavigationCallback", fn)}
14538+}
14539+
14540+func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) Run(run func(fn func(uri string))) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call {
14541+ _c.Call.Run(func(args mock.Arguments) {
14542+ var arg0 func(uri string)
14543+ if args[0] != nil {
14544+ arg0 = args[0].(func(uri string))
14545+ }
14546+ run(
14547+ arg0,
14548+ )
14549+ })
14550+ return _c
14551+}
14552+
14553+func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) Return() *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call {
14554+ _c.Call.Return()
14555+ return _c
14556+}
14557+
14558+func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) RunAndReturn(run func(fn func(uri string))) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call {
14559+ _c.Run(run)
14560+ return _c
14561+}
14562+
14563+// EnablePopupOpenerBridge provides a mock function for the type MockPopupOpenerCapable
14564+func (_mock *MockPopupOpenerCapable) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
14565+ _mock.Called(parent, noJavaScriptAccess)
14566+ return
14567+}
14568+
14569+// MockPopupOpenerCapable_EnablePopupOpenerBridge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnablePopupOpenerBridge'
14570+type MockPopupOpenerCapable_EnablePopupOpenerBridge_Call struct {
14571+ *mock.Call
14572+}
14573+
14574+// EnablePopupOpenerBridge is a helper method to define mock.On call
14575+// - parent port.WebView
14576+// - noJavaScriptAccess bool
14577+func (_e *MockPopupOpenerCapable_Expecter) EnablePopupOpenerBridge(parent interface{}, noJavaScriptAccess interface{}) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call {
14578+ return &MockPopupOpenerCapable_EnablePopupOpenerBridge_Call{Call: _e.mock.On("EnablePopupOpenerBridge", parent, noJavaScriptAccess)}
14579+}
14580+
14581+func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) Run(run func(parent port.WebView, noJavaScriptAccess bool)) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call {
14582+ _c.Call.Run(func(args mock.Arguments) {
14583+ var arg0 port.WebView
14584+ if args[0] != nil {
14585+ arg0 = args[0].(port.WebView)
14586+ }
14587+ var arg1 bool
14588+ if args[1] != nil {
14589+ arg1 = args[1].(bool)
14590+ }
14591+ run(
14592+ arg0,
14593+ arg1,
14594+ )
14595+ })
14596+ return _c
14597+}
14598+
14599+func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) Return() *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call {
14600+ _c.Call.Return()
14601+ return _c
14602+}
14603+
14604+func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) RunAndReturn(run func(parent port.WebView, noJavaScriptAccess bool)) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call {
14605+ _c.Run(run)
14606+ return _c
14607+}
14608+
14609+// HasActivePopupOpenerBridge provides a mock function for the type MockPopupOpenerCapable
14610+func (_mock *MockPopupOpenerCapable) HasActivePopupOpenerBridge() bool {
14611+ ret := _mock.Called()
14612+
14613+ if len(ret) == 0 {
14614+ panic("no return value specified for HasActivePopupOpenerBridge")
14615+ }
14616+
14617+ var r0 bool
14618+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
14619+ r0 = returnFunc()
14620+ } else {
14621+ r0 = ret.Get(0).(bool)
14622+ }
14623+ return r0
14624+}
14625+
14626+// MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasActivePopupOpenerBridge'
14627+type MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call struct {
14628+ *mock.Call
14629+}
14630+
14631+// HasActivePopupOpenerBridge is a helper method to define mock.On call
14632+func (_e *MockPopupOpenerCapable_Expecter) HasActivePopupOpenerBridge() *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call {
14633+ return &MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call{Call: _e.mock.On("HasActivePopupOpenerBridge")}
14634+}
14635+
14636+func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) Run(run func()) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call {
14637+ _c.Call.Run(func(args mock.Arguments) {
14638+ run()
14639+ })
14640+ return _c
14641+}
14642+
14643+func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) Return(b bool) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call {
14644+ _c.Call.Return(b)
14645+ return _c
14646+}
14647+
14648+func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) RunAndReturn(run func() bool) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call {
14649+ _c.Call.Return(run)
14650+ return _c
14651+}
14652+
1441614653 // NewMockOAuthCallbackCapable creates a new instance of MockOAuthCallbackCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1441714654 // The first argument is typically a *testing.T value.
1441814655 func NewMockOAuthCallbackCapable(t interface {
M internal/application/port/webview.go +24 -12
@@ -3,9 +3,7 @@
33 // remain independent of specific implementations (WebKit, GTK, etc.).
44 package port
55
6-import (
7- "context"
8-)
6+import "context"
97
108 // WebViewID uniquely identifies a WebView instance.
119 type WebViewID uint64
@@ -64,10 +62,11 @@ const (
6462
6563 // PopupRequest contains metadata about a popup window request.
6664 type PopupRequest struct {
67- TargetURI string
68- FrameName string // e.g., "_blank", custom name, or empty
69- IsUserGesture bool
70- ParentViewID WebViewID
65+ TargetURI string
66+ FrameName string // e.g., "_blank", custom name, or empty
67+ IsUserGesture bool
68+ NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
69+ ParentViewID WebViewID
7170 }
7271
7372 // Texture represents a graphics texture returned by the engine.
@@ -314,14 +313,27 @@ type Printer interface {
314313 PrintPage()
315314 }
316315
317-// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.
318-// SetOnClose composes the provided function with any existing close handler so
319-// that multiple callers can each register a close callback without overwriting
320-// one another.
321-type PopupCapable interface {
316+// PopupLifecycleCapable is implemented by WebViews that support the full popup
317+// pane lifecycle. SetOnClose composes the provided function with any existing
318+// close handler so multiple callers can register close hooks without
319+// overwriting one another. PrimePopupNavigation lets engines preserve the popup
320+// target URI before the browser is fully created.
321+type PopupLifecycleCapable interface {
322322 SetOnReadyToShow(fn func())
323323 SetOnClose(fn func())
324324 Show()
325+ PrimePopupNavigation(uri string)
326+}
327+
328+// PopupOpenerCapable is implemented by popup WebViews that can emulate opener
329+// semantics when a popup must be created directly instead of through a native
330+// related-popup lifecycle, and that can report or observe synthetic opener
331+// traffic needed by OAuth flows.
332+type PopupOpenerCapable interface {
333+ EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)
334+ AddOpenerMessageCallback(fn func())
335+ AddOpenerNavigationCallback(fn func(uri string))
336+ HasActivePopupOpenerBridge() bool
325337 }
326338
327339 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
A internal/domain/errors/errors.go +11 -0
@@ -0,0 +1,11 @@
1+package domainerrors
2+
3+type domainError string
4+
5+func (e domainError) Error() string {
6+ return string(e)
7+}
8+
9+// ErrRelatedWebViewUnsupported reports that the active engine cannot create
10+// a popup WebView related to an existing parent WebView.
11+var ErrRelatedWebViewUnsupported error = domainError("related popup webviews are not supported")
M internal/infrastructure/cef/app.go +9 -7
@@ -45,6 +45,7 @@ func configureCommandLine(commandLine purecef.CommandLine) {
4545 type dumberApp struct {
4646 engine *Engine
4747 bph purecef.BrowserProcessHandler
48+ rph purecef.RenderProcessHandler
4849 }
4950
5051 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -53,15 +54,16 @@ type dumberApp struct {
5354 func newDumberApp(engine *Engine) purecef.App {
5455 app := &dumberApp{engine: engine}
5556 app.bph = &dumberBPH{engine: engine}
57+ app.rph = newPopupOpenerRenderProcessHandler()
5658 return app
5759 }
5860
5961 // NewSubprocessApp returns a lightweight raw App implementation for CEF
60-// subprocesses re-executed from the main dumber binary. Keep subprocesses free
61-// of custom render handlers for now: the renderer bridge introduced a startup
62-// regression where OSR pages stopped producing their first paint.
62+// subprocesses re-executed from the main dumber binary. Keep the legacy
63+// renderer bridge disabled, but allow the minimal popup-opener render handler
64+// needed to install synthetic opener state before page scripts run.
6365 func NewSubprocessApp() purecef.App {
64- return &subprocessApp{}
66+ return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}
6567 }
6668
6769 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -91,9 +93,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
9193 }
9294 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
9395 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
94-func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
96+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }
9597
96-type subprocessApp struct{}
98+type subprocessApp struct{ rph purecef.RenderProcessHandler }
9799
98100 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
99101 configureCommandLine(commandLine)
@@ -103,7 +105,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
103105 }
104106 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
105107 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
106-func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
108+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }
107109
108110 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
109111 // child process launch tracking, and diagnostic logging.
A internal/infrastructure/cef/bridge_script_templates.go +808 -0
@@ -0,0 +1,808 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/webutil"
8+)
9+
10+// trustedPageFetchBridgeBaseJS is the invariant fetch-bridge bundle injected on
11+// external pages. It is assembled once from shared templates and then only the
12+// bridge nonce placeholder is replaced per navigation.
13+var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()
14+
15+// rendererBridgeExtensionJS is assembled once from the shared popup proxy
16+// builder so the renderer-extension and fetch-based popup bridges stay aligned.
17+var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()
18+
19+type popupProxyBridgeDispatch struct {
20+ patchFlag string
21+ helpers string
22+}
23+
24+const popupProxyBridgeBodyTemplateJS = `
25+ if (typeof window === 'undefined') return;
26+ if (window.__DUMBER_POPUP_PATCH_FLAG__) return;
27+
28+ __DUMBER_POPUP_DISPATCH_HELPERS__
29+
30+ function resolvePopupURL(rawURL) {
31+ if (rawURL == null || rawURL === '') return '';
32+ try {
33+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
34+ } catch (_) {
35+ return String(rawURL);
36+ }
37+ }
38+
39+ function hasUserGesture() {
40+ try {
41+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
42+ } catch (_) {
43+ return false;
44+ }
45+ }
46+
47+ function popupHasNoOpener(features) {
48+ if (features == null || features === '') return false;
49+ var normalized = String(features).toLowerCase();
50+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
51+ }
52+
53+ function shouldDelegateWindowOpen(target) {
54+ return target === '_self' || target === '_top' || target === '_parent';
55+ }
56+
57+ function createPopupProxyID() {
58+ var timestamp = Date.now();
59+ try {
60+ if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') {
61+ var bytes = new Uint8Array(4);
62+ crypto.getRandomValues(bytes);
63+ var nonce = '';
64+ for (var i = 0; i < bytes.length; i++) {
65+ nonce += ('0' + bytes[i].toString(16)).slice(-2);
66+ }
67+ return 'popup-' + timestamp + '-' + nonce;
68+ }
69+ } catch (_) {}
70+ return 'popup-' + timestamp + '-' + Math.random().toString(36).slice(2);
71+ }
72+
73+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
74+ var href = initialURL || 'about:blank';
75+ var closed = false;
76+ var noJavaScriptAccess = popupHasNoOpener(features);
77+
78+ function navigate(nextURL) {
79+ href = resolvePopupURL(nextURL);
80+ dispatchPopupNavigate(proxyID, href);
81+ return href;
82+ }
83+
84+ var locationProxy = {
85+ assign: function(nextURL) { return navigate(nextURL); },
86+ replace: function(nextURL) { return navigate(nextURL); },
87+ toString: function() { return href; }
88+ };
89+ try {
90+ Object.defineProperty(locationProxy, 'href', {
91+ configurable: true,
92+ enumerable: true,
93+ get: function() { return href; },
94+ set: function(nextURL) { navigate(nextURL); }
95+ });
96+ } catch (_) {
97+ locationProxy.href = href;
98+ }
99+
100+ var proxy = {
101+ blur: function() { return undefined; },
102+ close: function() {
103+ if (closed) return;
104+ closed = true;
105+ dispatchPopupClose(proxyID);
106+ },
107+ focus: function() { return undefined; },
108+ postMessage: function() { return undefined; }
109+ };
110+ proxy.opener = noJavaScriptAccess ? null : window;
111+ proxy.self = proxy;
112+ proxy.window = proxy;
113+
114+ try {
115+ Object.defineProperty(proxy, 'closed', {
116+ configurable: true,
117+ enumerable: true,
118+ get: function() { return closed; },
119+ set: function(next) { closed = !!next; }
120+ });
121+ } catch (_) {
122+ proxy.closed = false;
123+ }
124+
125+ try {
126+ Object.defineProperty(proxy, 'location', {
127+ configurable: true,
128+ enumerable: true,
129+ get: function() { return locationProxy; },
130+ set: function(nextURL) { navigate(nextURL); }
131+ });
132+ } catch (_) {
133+ proxy.location = locationProxy;
134+ }
135+
136+ try {
137+ Object.defineProperty(proxy, Symbol.toStringTag, {
138+ configurable: true,
139+ value: 'Window'
140+ });
141+ } catch (_) {}
142+
143+ return proxy;
144+ }
145+
146+ try {
147+ var originalWindowOpen = typeof window.open === 'function' ? window.open : null;
148+ if (!originalWindowOpen) return;
149+
150+ window.__DUMBER_POPUP_PATCH_FLAG__ = true;
151+ window.open = function(url, target, features) {
152+ var normalizedTarget = target == null ? '' : String(target);
153+ if (shouldDelegateWindowOpen(normalizedTarget)) {
154+ return originalWindowOpen.apply(this, arguments);
155+ }
156+
157+ var proxyID = createPopupProxyID();
158+ var resolvedURL = resolvePopupURL(url);
159+ var noJavaScriptAccess = popupHasNoOpener(features);
160+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
161+ dispatchPopupOpen({
162+ proxy_id: proxyID,
163+ url: resolvedURL,
164+ frame_name: normalizedTarget,
165+ user_gesture: hasUserGesture(),
166+ no_javascript_access: noJavaScriptAccess
167+ });
168+ return popupProxy;
169+ };
170+ } catch (_) {
171+ window.__DUMBER_POPUP_PATCH_FLAG__ = false;
172+ }
173+`
174+
175+const popupFetchBridgeHelpersJSTemplate = `
176+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
177+
178+ function encodeBody(body) {
179+ try {
180+ return btoa(unescape(encodeURIComponent(body)));
181+ } catch (_) {
182+ return '';
183+ }
184+ }
185+
186+ function postBridge(url, payload) {
187+ var encoded = encodeBody(JSON.stringify(payload));
188+ if (!encoded) return;
189+ fetch(url, {
190+ method: 'POST',
191+ headers: {
192+ 'X-Dumber-Body': encoded,
193+ 'X-Dumber-Bridge-Nonce': bridgeNonce
194+ }
195+ }).catch(function() {});
196+ }
197+
198+ function dispatchPopupOpen(payload) {
199+ postBridge('dumb:///api/popup-open', payload);
200+ }
201+
202+ function dispatchPopupNavigate(proxyID, url) {
203+ postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: url });
204+ }
205+
206+ function dispatchPopupClose(proxyID) {
207+ postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
208+ }
209+`
210+
211+const rendererPopupDispatchHelpersJS = `
212+ function dispatchPopupOpen(payload) {
213+ send('popup_open', JSON.stringify(payload));
214+ }
215+
216+ function dispatchPopupNavigate(proxyID, url) {
217+ send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: url }));
218+ }
219+
220+ function dispatchPopupClose(proxyID) {
221+ send('popup_close', JSON.stringify({ proxy_id: proxyID }));
222+ }
223+`
224+
225+func buildPopupProxyBridgeBodyJS(dispatch popupProxyBridgeDispatch) string {
226+ replacer := strings.NewReplacer(
227+ "__DUMBER_POPUP_PATCH_FLAG__", dispatch.patchFlag,
228+ "__DUMBER_POPUP_DISPATCH_HELPERS__", dispatch.helpers,
229+ )
230+ return replacer.Replace(popupProxyBridgeBodyTemplateJS)
231+}
232+
233+func wrapSelfExecutingScript(body string) string {
234+ return fmt.Sprintf("(function() {\n%s\n})();", body)
235+}
236+
237+func buildTrustedPageFetchBridgeBaseJS() string {
238+ popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
239+ patchFlag: "__dumberPopupBridgePatched",
240+ helpers: popupFetchBridgeHelpersJSTemplate,
241+ }))
242+ return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge
243+}
244+
245+const bridgeNoncePlaceholder = "__DUMBER_BRIDGE_NONCE__"
246+
247+func buildTrustedPageFetchBridgeJS(bridgeNonce string) string {
248+ if strings.Contains(bridgeNonce, bridgeNoncePlaceholder) {
249+ panic("bridge nonce contains reserved placeholder")
250+ }
251+ return strings.ReplaceAll(trustedPageFetchBridgeBaseJS, bridgeNoncePlaceholder, bridgeNonce)
252+}
253+
254+func buildRendererBridgeExtensionJS() string {
255+ popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
256+ patchFlag: "__dumberPopupOpenPatched",
257+ helpers: rendererPopupDispatchHelpersJS,
258+ })
259+ return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody)
260+}
261+
262+func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string {
263+ replacer := strings.NewReplacer(
264+ "__DUMBER_BRIDGE_NONCE__", webutil.EscapeForJSString(bridgeNonce),
265+ "__DUMBER_PARENT_URI__", webutil.EscapeForJSString(parentURI),
266+ )
267+ return replacer.Replace(popupOpenerBridgeTemplateJS)
268+}
269+
270+// clipboardSelectionFetchBridgeJSTemplate keeps the lightweight fetch bridge
271+// enabled while the native renderer bridge remains disabled due to an OSR
272+// startup regression. It mirrors explicit clipboard text from DOM selections,
273+// safe input/textarea selections, and async Clipboard API writes, and also
274+// reasserts browser focus when an editable element gains DOM focus.
275+const clipboardSelectionFetchBridgeJSTemplate = `(function(){
276+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
277+
278+ function encodeBody(body) {
279+ return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
280+ }
281+
282+ function inputType(node) {
283+ return node && node.type ? String(node.type).toLowerCase() : 'text';
284+ }
285+
286+ function isTextEditableInputType(type) {
287+ switch ((type || 'text').toLowerCase()) {
288+ case 'button':
289+ case 'submit':
290+ case 'reset':
291+ case 'checkbox':
292+ case 'radio':
293+ case 'range':
294+ case 'color':
295+ case 'file':
296+ case 'image':
297+ case 'hidden':
298+ return false;
299+ default:
300+ return true;
301+ }
302+ }
303+
304+ function isSelectionReadableInputType(type) {
305+ return isTextEditableInputType(type) && type !== 'password';
306+ }
307+
308+ function getActiveElementSelection() {
309+ var el = document.activeElement;
310+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
311+ var tag = el.tagName;
312+ if (el.disabled || el.readOnly) return '';
313+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
314+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
315+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
316+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
317+ if (end > start) return el.value.slice(start, end);
318+ return '';
319+ }
320+
321+ function getSelectedText() {
322+ var activeSelection = getActiveElementSelection();
323+ if (activeSelection) return activeSelection;
324+ var sel = window.getSelection();
325+ return sel ? sel.toString() : '';
326+ }
327+
328+ function isEditable(node) {
329+ if (!node || node.nodeType !== 1) return false;
330+ if (node.isContentEditable) return true;
331+ var tag = node.tagName;
332+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
333+ if (tag !== 'INPUT') return false;
334+ if (node.disabled || node.readOnly) return false;
335+ return isTextEditableInputType(inputType(node));
336+ }
337+
338+ function sendToClipboard(text) {
339+ if (!text) return;
340+ var body = JSON.stringify({text: text});
341+ var encoded = encodeBody(body);
342+ if (!encoded) return;
343+ fetch('dumb:///api/clipboard-set', {
344+ method: 'POST',
345+ headers: {
346+ 'X-Dumber-Body': encoded,
347+ 'X-Dumber-Bridge-Nonce': bridgeNonce
348+ }
349+ }).catch(function(){});
350+ }
351+
352+ function sendFocusSync() {
353+ fetch('dumb:///api/focus-sync', {
354+ method: 'POST',
355+ headers: {
356+ 'X-Dumber-Bridge-Action': 'focus-sync',
357+ 'X-Dumber-Bridge-Nonce': bridgeNonce
358+ }
359+ }).catch(function(){});
360+ }
361+
362+ function mirrorClipboardItems(items) {
363+ try {
364+ if (!items || typeof items.length !== 'number') return;
365+ Array.prototype.forEach.call(items, function(item) {
366+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
367+ Promise.resolve(item.getType('text/plain'))
368+ .then(function(blob) {
369+ if (!blob || typeof blob.text !== 'function') return '';
370+ return blob.text();
371+ })
372+ .then(function(text) {
373+ if (text) sendToClipboard(text);
374+ })
375+ .catch(function(){});
376+ });
377+ } catch (_) {}
378+ }
379+
380+ document.addEventListener('copy', function(event) {
381+ if (event && event.isTrusted === false) return;
382+ var text = getSelectedText();
383+ if (text) sendToClipboard(text);
384+ });
385+ document.addEventListener('cut', function(event) {
386+ if (event && event.isTrusted === false) return;
387+ var text = getSelectedText();
388+ if (text) sendToClipboard(text);
389+ });
390+ document.addEventListener('focusin', function(event) {
391+ if (event && event.isTrusted === false) return;
392+ if (isEditable(event && event.target)) sendFocusSync();
393+ }, true);
394+
395+ try {
396+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
397+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
398+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
399+
400+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
401+ clipboardProto.__dumberWriteTextPatched = true;
402+ var originalProtoWriteText = clipboardProto.writeText;
403+ var wrappedProtoWriteText = function(text) {
404+ var normalized = text == null ? '' : String(text);
405+ var result = originalProtoWriteText.call(this, normalized);
406+ if (result && typeof result.then === 'function') {
407+ return result.then(function(value) {
408+ if (normalized) sendToClipboard(normalized);
409+ return value;
410+ });
411+ }
412+ if (normalized) sendToClipboard(normalized);
413+ return result;
414+ };
415+ try {
416+ Object.defineProperty(clipboardProto, 'writeText', {
417+ configurable: true,
418+ writable: true,
419+ value: wrappedProtoWriteText
420+ });
421+ } catch (_) {
422+ clipboardProto.writeText = wrappedProtoWriteText;
423+ }
424+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
425+ window.__dumberClipboardWriteTextPatched = true;
426+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
427+ var wrappedWriteText = function(text) {
428+ var normalized = text == null ? '' : String(text);
429+ var result = originalWriteText(normalized);
430+ if (result && typeof result.then === 'function') {
431+ return result.then(function(value) {
432+ if (normalized) sendToClipboard(normalized);
433+ return value;
434+ });
435+ }
436+ if (normalized) sendToClipboard(normalized);
437+ return result;
438+ };
439+ try {
440+ Object.defineProperty(clipboardObj, 'writeText', {
441+ configurable: true,
442+ writable: true,
443+ value: wrappedWriteText
444+ });
445+ } catch (_) {
446+ clipboardObj.writeText = wrappedWriteText;
447+ }
448+ }
449+
450+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
451+ clipboardProto.__dumberWritePatched = true;
452+ var originalProtoWrite = clipboardProto.write;
453+ var wrappedProtoWrite = function(items) {
454+ var result = originalProtoWrite.apply(this, arguments);
455+ if (result && typeof result.then === 'function') {
456+ return result.then(function(value) {
457+ mirrorClipboardItems(items);
458+ return value;
459+ });
460+ }
461+ mirrorClipboardItems(items);
462+ return result;
463+ };
464+ try {
465+ Object.defineProperty(clipboardProto, 'write', {
466+ configurable: true,
467+ writable: true,
468+ value: wrappedProtoWrite
469+ });
470+ } catch (_) {
471+ clipboardProto.write = wrappedProtoWrite;
472+ }
473+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
474+ window.__dumberClipboardWritePatched = true;
475+ var originalWrite = clipboardObj.write.bind(clipboardObj);
476+ var wrappedWrite = function(items) {
477+ var result = originalWrite(items);
478+ if (result && typeof result.then === 'function') {
479+ return result.then(function(value) {
480+ mirrorClipboardItems(items);
481+ return value;
482+ });
483+ }
484+ mirrorClipboardItems(items);
485+ return result;
486+ };
487+ try {
488+ Object.defineProperty(clipboardObj, 'write', {
489+ configurable: true,
490+ writable: true,
491+ value: wrappedWrite
492+ });
493+ } catch (_) {
494+ clipboardObj.write = wrappedWrite;
495+ }
496+ }
497+ } catch (_) {}
498+
499+ if (isEditable(document.activeElement)) sendFocusSync();
500+})();`
501+
502+const rendererBridgeExtensionTemplateJS = `
503+(function() {
504+ native function Dispatch(action, payload);
505+
506+ function send(action, payload) {
507+ return Dispatch(action, payload == null ? '' : String(payload));
508+ }
509+
510+ function isEditable(node) {
511+ if (!node || node.nodeType !== 1) return false;
512+ if (node.isContentEditable) return true;
513+ var tag = node.tagName;
514+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
515+ if (node.disabled || node.readOnly) return false;
516+ return true;
517+ }
518+
519+ function getActiveElementSelection() {
520+ var el = document.activeElement;
521+ if (!el) return '';
522+ var tag = el.tagName;
523+ if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
524+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
525+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
526+ if (end > start) return el.value.slice(start, end);
527+ }
528+ return '';
529+ }
530+
531+ function getSelectedText() {
532+ var activeSelection = getActiveElementSelection();
533+ if (activeSelection) return activeSelection;
534+ var sel = window.getSelection();
535+ return sel ? sel.toString() : '';
536+ }
537+
538+ function notifyExplicitTextCopy(action, text) {
539+ var normalizedText = text == null ? '' : String(text);
540+ var normalizedAction = action == null || action === '' ? 'copy' : String(action);
541+ return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
542+ }
543+
544+__DUMBER_POPUP_PROXY_BRIDGE_BODY__
545+
546+ if (typeof document !== 'undefined' && document.addEventListener) {
547+ document.addEventListener('focusin', function(event) {
548+ if (isEditable(event.target)) {
549+ send('focus_sync', '');
550+ }
551+ }, true);
552+
553+ function mirrorClipboardEvent(action, e) {
554+ if (!e.isTrusted) return;
555+ var capturedText = '';
556+ if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
557+ try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
558+ }
559+ setTimeout(function() {
560+ var text = capturedText;
561+ if (!text && !e.defaultPrevented) text = getSelectedText();
562+ if (text) notifyExplicitTextCopy(action, text);
563+ }, 0);
564+ }
565+
566+ document.addEventListener('copy', function(e) {
567+ mirrorClipboardEvent('copy', e);
568+ }, true);
569+ document.addEventListener('cut', function(e) {
570+ mirrorClipboardEvent('cut', e);
571+ }, true);
572+ }
573+ try {
574+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
575+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
576+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
577+
578+ function mirrorClipboardItems(items) {
579+ try {
580+ if (!items || typeof items.length !== 'number') return;
581+ Array.prototype.forEach.call(items, function(item) {
582+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
583+ Promise.resolve(item.getType('text/plain'))
584+ .then(function(blob) {
585+ if (!blob || typeof blob.text !== 'function') return '';
586+ return blob.text();
587+ })
588+ .then(function(text) {
589+ if (text) notifyExplicitTextCopy('copy', text);
590+ })
591+ .catch(function() {});
592+ });
593+ } catch (_) {}
594+ }
595+
596+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
597+ clipboardProto.__dumberWriteTextPatched = true;
598+ var originalProtoWriteText = clipboardProto.writeText;
599+ var wrappedProtoWriteText = function(text) {
600+ var normalized = text == null ? '' : String(text);
601+ var result = originalProtoWriteText.call(this, normalized);
602+ if (result && typeof result.then === 'function') {
603+ return result.then(function(value) {
604+ if (normalized) notifyExplicitTextCopy('copy', normalized);
605+ return value;
606+ });
607+ }
608+ if (normalized) notifyExplicitTextCopy('copy', normalized);
609+ return result;
610+ };
611+ try {
612+ Object.defineProperty(clipboardProto, 'writeText', {
613+ configurable: true,
614+ writable: true,
615+ value: wrappedProtoWriteText
616+ });
617+ } catch (_) {
618+ clipboardProto.writeText = wrappedProtoWriteText;
619+ }
620+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
621+ window.__dumberClipboardWriteTextPatched = true;
622+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
623+ var wrappedWriteText = function(text) {
624+ var normalized = text == null ? '' : String(text);
625+ var result = originalWriteText(normalized);
626+ if (result && typeof result.then === 'function') {
627+ return result.then(function(value) {
628+ if (normalized) notifyExplicitTextCopy('copy', normalized);
629+ return value;
630+ });
631+ }
632+ if (normalized) notifyExplicitTextCopy('copy', normalized);
633+ return result;
634+ };
635+ try {
636+ Object.defineProperty(clipboardObj, 'writeText', {
637+ configurable: true,
638+ writable: true,
639+ value: wrappedWriteText
640+ });
641+ } catch (_) {
642+ clipboardObj.writeText = wrappedWriteText;
643+ }
644+ }
645+
646+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
647+ clipboardProto.__dumberWritePatched = true;
648+ var originalProtoWrite = clipboardProto.write;
649+ var wrappedProtoWrite = function(items) {
650+ var result = originalProtoWrite.apply(this, arguments);
651+ if (result && typeof result.then === 'function') {
652+ return result.then(function(value) {
653+ mirrorClipboardItems(items);
654+ return value;
655+ });
656+ }
657+ mirrorClipboardItems(items);
658+ return result;
659+ };
660+ try {
661+ Object.defineProperty(clipboardProto, 'write', {
662+ configurable: true,
663+ writable: true,
664+ value: wrappedProtoWrite
665+ });
666+ } catch (_) {
667+ clipboardProto.write = wrappedProtoWrite;
668+ }
669+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
670+ window.__dumberClipboardWritePatched = true;
671+ var originalWrite = clipboardObj.write.bind(clipboardObj);
672+ var wrappedWrite = function(items) {
673+ var result = originalWrite(items);
674+ if (result && typeof result.then === 'function') {
675+ return result.then(function(value) {
676+ mirrorClipboardItems(items);
677+ return value;
678+ });
679+ }
680+ mirrorClipboardItems(items);
681+ return result;
682+ };
683+ try {
684+ Object.defineProperty(clipboardObj, 'write', {
685+ configurable: true,
686+ writable: true,
687+ value: wrappedWrite
688+ });
689+ } catch (_) {
690+ clipboardObj.write = wrappedWrite;
691+ }
692+ }
693+ } catch (_) {}
694+
695+ send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
696+})();
697+`
698+
699+const popupOpenerBridgeTemplateJS = `(function() {
700+ if (typeof window === 'undefined') return;
701+ if (window.__dumberPopupOpenerBridgeInstalled) return;
702+ window.__dumberPopupOpenerBridgeInstalled = true;
703+ if (window.opener != null) return;
704+
705+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
706+ var openerHref = '__DUMBER_PARENT_URI__';
707+
708+ function postBridge(path, payload) {
709+ try {
710+ fetch('dumb:///api/' + path, {
711+ method: 'POST',
712+ headers: {
713+ 'Content-Type': 'application/json',
714+ 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
715+ 'X-Dumber-Bridge-Nonce': bridgeNonce
716+ }
717+ }).catch(function() {});
718+ } catch (_) {}
719+ }
720+
721+ function normalizeURLValue(rawURL) {
722+ if (rawURL == null) return '';
723+ try {
724+ return String(rawURL);
725+ } catch (_) {
726+ return '';
727+ }
728+ }
729+
730+ function navigateOpener(nextURL) {
731+ var rawURL = normalizeURLValue(nextURL);
732+ if (rawURL !== '') openerHref = rawURL;
733+ postBridge('popup-opener-navigate', { url: rawURL });
734+ return openerHref;
735+ }
736+
737+ function serializeMessage(value) {
738+ try {
739+ return { kind: 'json', value: JSON.stringify(value) };
740+ } catch (_) {
741+ return { kind: 'string', value: String(value) };
742+ }
743+ }
744+
745+ var locationProxy = {
746+ assign: function(nextURL) { return navigateOpener(nextURL); },
747+ replace: function(nextURL) { return navigateOpener(nextURL); },
748+ toString: function() { return openerHref; }
749+ };
750+ try {
751+ Object.defineProperty(locationProxy, 'href', {
752+ configurable: true,
753+ enumerable: true,
754+ get: function() { return openerHref; },
755+ set: function(nextURL) { navigateOpener(nextURL); }
756+ });
757+ } catch (_) {
758+ locationProxy.href = openerHref;
759+ }
760+
761+ var openerProxy = {
762+ blur: function() { return undefined; },
763+ close: function() { return undefined; },
764+ focus: function() { return undefined; },
765+ postMessage: function(message, targetOrigin) {
766+ var serialized = serializeMessage(message);
767+ postBridge('popup-opener-post-message', {
768+ data: serialized.value,
769+ data_kind: serialized.kind,
770+ target_origin: targetOrigin == null ? '*' : String(targetOrigin),
771+ source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
772+ source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
773+ });
774+ return undefined;
775+ }
776+ };
777+ openerProxy.self = openerProxy;
778+ openerProxy.window = openerProxy;
779+ try {
780+ Object.defineProperty(openerProxy, 'closed', {
781+ configurable: true,
782+ enumerable: true,
783+ get: function() { return false; }
784+ });
785+ } catch (_) {
786+ openerProxy.closed = false;
787+ }
788+ try {
789+ Object.defineProperty(openerProxy, 'location', {
790+ configurable: true,
791+ enumerable: true,
792+ get: function() { return locationProxy; },
793+ set: function(nextURL) { navigateOpener(nextURL); }
794+ });
795+ } catch (_) {
796+ openerProxy.location = locationProxy;
797+ }
798+
799+ try {
800+ Object.defineProperty(window, 'opener', {
801+ configurable: true,
802+ enumerable: true,
803+ get: function() { return openerProxy; }
804+ });
805+ } catch (_) {
806+ try { window.opener = openerProxy; } catch (_) {}
807+ }
808+})();`
M internal/infrastructure/cef/content_injector.go +4 -240
@@ -385,242 +385,6 @@ const redditDirectVideoJS = `(function(){
385385 }, 1000);
386386 })();`
387387
388-// clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389-// while the native renderer bridge remains disabled due to an OSR startup
390-// regression. It mirrors explicit clipboard text from DOM selections,
391-// safe input/textarea selections, and async Clipboard API writes, and also
392-// reasserts browser focus when an editable element gains DOM focus.
393-const clipboardSelectionFetchBridgeJS = `(function(){
394- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395-
396- function encodeBody(body) {
397- return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
398- }
399-
400- function inputType(node) {
401- return node && node.type ? String(node.type).toLowerCase() : 'text';
402- }
403-
404- function isTextEditableInputType(type) {
405- switch ((type || 'text').toLowerCase()) {
406- case 'button':
407- case 'submit':
408- case 'reset':
409- case 'checkbox':
410- case 'radio':
411- case 'range':
412- case 'color':
413- case 'file':
414- case 'image':
415- case 'hidden':
416- return false;
417- default:
418- return true;
419- }
420- }
421-
422- function isSelectionReadableInputType(type) {
423- return isTextEditableInputType(type) && type !== 'password';
424- }
425-
426- function getActiveElementSelection() {
427- var el = document.activeElement;
428- if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
429- var tag = el.tagName;
430- if (el.disabled || el.readOnly) return '';
431- if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
432- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
433- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
434- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
435- if (end > start) return el.value.slice(start, end);
436- return '';
437- }
438-
439- function getSelectedText() {
440- var activeSelection = getActiveElementSelection();
441- if (activeSelection) return activeSelection;
442- var sel = window.getSelection();
443- return sel ? sel.toString() : '';
444- }
445-
446- function isEditable(node) {
447- if (!node || node.nodeType !== 1) return false;
448- if (node.isContentEditable) return true;
449- var tag = node.tagName;
450- if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
451- if (tag !== 'INPUT') return false;
452- if (node.disabled || node.readOnly) return false;
453- return isTextEditableInputType(inputType(node));
454- }
455-
456- function sendToClipboard(text) {
457- if (!text) return;
458- var body = JSON.stringify({text: text});
459- var encoded = encodeBody(body);
460- if (!encoded) return;
461- fetch('dumb:///api/clipboard-set', {
462- method: 'POST',
463- headers: {
464- 'X-Dumber-Body': encoded,
465- 'X-Dumber-Bridge-Nonce': bridgeNonce
466- }
467- }).catch(function(){});
468- }
469-
470- function sendFocusSync() {
471- fetch('dumb:///api/focus-sync', {
472- method: 'POST',
473- headers: {
474- 'X-Dumber-Bridge-Action': 'focus-sync',
475- 'X-Dumber-Bridge-Nonce': bridgeNonce
476- }
477- }).catch(function(){});
478- }
479-
480- function mirrorClipboardItems(items) {
481- try {
482- if (!items || typeof items.length !== 'number') return;
483- Array.prototype.forEach.call(items, function(item) {
484- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
485- Promise.resolve(item.getType('text/plain'))
486- .then(function(blob) {
487- if (!blob || typeof blob.text !== 'function') return '';
488- return blob.text();
489- })
490- .then(function(text) {
491- if (text) sendToClipboard(text);
492- })
493- .catch(function(){});
494- });
495- } catch (_) {}
496- }
497-
498- document.addEventListener('copy', function(event) {
499- if (event && event.isTrusted === false) return;
500- var text = getSelectedText();
501- if (text) sendToClipboard(text);
502- });
503- document.addEventListener('cut', function(event) {
504- if (event && event.isTrusted === false) return;
505- var text = getSelectedText();
506- if (text) sendToClipboard(text);
507- });
508- document.addEventListener('focusin', function(event) {
509- if (event && event.isTrusted === false) return;
510- if (isEditable(event && event.target)) sendFocusSync();
511- }, true);
512-
513- try {
514- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
515- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
516- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
517-
518- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
519- clipboardProto.__dumberWriteTextPatched = true;
520- var originalProtoWriteText = clipboardProto.writeText;
521- var wrappedProtoWriteText = function(text) {
522- var normalized = text == null ? '' : String(text);
523- var result = originalProtoWriteText.call(this, normalized);
524- if (result && typeof result.then === 'function') {
525- return result.then(function(value) {
526- if (normalized) sendToClipboard(normalized);
527- return value;
528- });
529- }
530- if (normalized) sendToClipboard(normalized);
531- return result;
532- };
533- try {
534- Object.defineProperty(clipboardProto, 'writeText', {
535- configurable: true,
536- writable: true,
537- value: wrappedProtoWriteText
538- });
539- } catch (_) {
540- clipboardProto.writeText = wrappedProtoWriteText;
541- }
542- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
543- window.__dumberClipboardWriteTextPatched = true;
544- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
545- var wrappedWriteText = function(text) {
546- var normalized = text == null ? '' : String(text);
547- var result = originalWriteText(normalized);
548- if (result && typeof result.then === 'function') {
549- return result.then(function(value) {
550- if (normalized) sendToClipboard(normalized);
551- return value;
552- });
553- }
554- if (normalized) sendToClipboard(normalized);
555- return result;
556- };
557- try {
558- Object.defineProperty(clipboardObj, 'writeText', {
559- configurable: true,
560- writable: true,
561- value: wrappedWriteText
562- });
563- } catch (_) {
564- clipboardObj.writeText = wrappedWriteText;
565- }
566- }
567-
568- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
569- clipboardProto.__dumberWritePatched = true;
570- var originalProtoWrite = clipboardProto.write;
571- var wrappedProtoWrite = function(items) {
572- var result = originalProtoWrite.apply(this, arguments);
573- if (result && typeof result.then === 'function') {
574- return result.then(function(value) {
575- mirrorClipboardItems(items);
576- return value;
577- });
578- }
579- mirrorClipboardItems(items);
580- return result;
581- };
582- try {
583- Object.defineProperty(clipboardProto, 'write', {
584- configurable: true,
585- writable: true,
586- value: wrappedProtoWrite
587- });
588- } catch (_) {
589- clipboardProto.write = wrappedProtoWrite;
590- }
591- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
592- window.__dumberClipboardWritePatched = true;
593- var originalWrite = clipboardObj.write.bind(clipboardObj);
594- var wrappedWrite = function(items) {
595- var result = originalWrite(items);
596- if (result && typeof result.then === 'function') {
597- return result.then(function(value) {
598- mirrorClipboardItems(items);
599- return value;
600- });
601- }
602- mirrorClipboardItems(items);
603- return result;
604- };
605- try {
606- Object.defineProperty(clipboardObj, 'write', {
607- configurable: true,
608- writable: true,
609- value: wrappedWrite
610- });
611- } catch (_) {
612- clipboardObj.write = wrappedWrite;
613- }
614- }
615- } catch (_) {}
616-
617- if (isEditable(document.activeElement)) sendFocusSync();
618-})();`
619-
620-func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
621- return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
622-}
623-
624388 // contentInjector implements port.ContentInjector for the CEF engine.
625389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
626390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -741,9 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
741505 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
742506 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
743507
744- // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
745- // mode while the native renderer bridge remains disabled.
746- bridgeNonce := wv.rotateBridgeNonce()
508+ // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
509+ // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
510+ bridgeNonce := wv.ensureBridgeNonce()
747511 if bridgeNonce == "" {
748512 ctx := context.Background()
749513 if ci != nil && ci.engine != nil {
@@ -751,7 +515,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
751515 }
752516 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753517 } else {
754- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
518+ wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce))
755519 }
756520
757521 // Video playback diagnostic — logs video element state changes.
M internal/infrastructure/cef/content_injector_test.go +40 -6
@@ -1,17 +1,18 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/stretchr/testify/require"
78 )
89
9-func testClipboardSelectionFetchBridgeJS() string {
10- return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
10+func testTrustedPageFetchBridgeJS() string {
11+ return buildTrustedPageFetchBridgeJS("test-bridge-nonce")
1112 }
1213
1314 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
14- script := testClipboardSelectionFetchBridgeJS()
15+ script := testTrustedPageFetchBridgeJS()
1516
1617 require.Contains(t, script, "document.activeElement")
1718 require.Contains(t, script, "selectionStart")
@@ -21,7 +22,7 @@ func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *t
2122 }
2223
2324 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
24- script := testClipboardSelectionFetchBridgeJS()
25+ script := testTrustedPageFetchBridgeJS()
2526
2627 require.Contains(t, script, "dumb:///api/focus-sync")
2728 require.Contains(t, script, "X-Dumber-Bridge-Action")
@@ -36,7 +37,7 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
3637 }
3738
3839 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
39- script := testClipboardSelectionFetchBridgeJS()
40+ script := testTrustedPageFetchBridgeJS()
4041
4142 require.Contains(t, script, "dumb:///api/clipboard-set")
4243 require.Contains(t, script, "X-Dumber-Body")
@@ -47,7 +48,7 @@ func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testin
4748 }
4849
4950 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
50- script := testClipboardSelectionFetchBridgeJS()
51+ script := testTrustedPageFetchBridgeJS()
5152
5253 require.Contains(t, script, "navigator && navigator.clipboard")
5354 require.Contains(t, script, "clipboardProto.writeText")
@@ -61,3 +62,36 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
6162 require.NotContains(t, script, "setBridgeNonce")
6263 require.Contains(t, script, "test-bridge-nonce")
6364 }
65+
66+func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
67+ script := testTrustedPageFetchBridgeJS()
68+
69+ require.Contains(t, script, "window.__dumberPopupBridgePatched")
70+ require.Contains(t, script, "window.open = function(url, target, features)")
71+ require.Contains(t, script, "dumb:///api/popup-open")
72+ require.Contains(t, script, "dumb:///api/popup-navigate")
73+ require.Contains(t, script, "dumb:///api/popup-close")
74+ require.Contains(t, script, "proxy_id")
75+ require.Contains(t, script, "createSyntheticPopupProxy")
76+ require.Contains(t, script, "createPopupProxyID")
77+ require.Contains(t, script, "crypto.getRandomValues")
78+ require.Contains(t, script, "no_javascript_access")
79+ require.Contains(t, script, "Object.defineProperty(proxy, 'closed'")
80+ require.Contains(t, script, "test-bridge-nonce")
81+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
82+}
83+
84+func TestPopupOpenerBridgeScript_InstallsSyntheticOpenerCallbacks(t *testing.T) {
85+ parent := &WebView{ctx: context.Background()}
86+ parent.updateURI("https://example.com/app")
87+ popup := &WebView{ctx: context.Background(), popupOpenerBridgeParent: parent, popupOpenerBridgeParentURI: "https://example.com/app"}
88+
89+ script := popup.popupOpenerBridgeScript("bridge-nonce")
90+
91+ require.Contains(t, script, "window.__dumberPopupOpenerBridgeInstalled")
92+ require.Contains(t, script, "popup-opener-navigate")
93+ require.Contains(t, script, "popup-opener-post-message")
94+ require.Contains(t, script, "Object.defineProperty(window, 'opener'")
95+ require.Contains(t, script, "https://example.com/app")
96+ require.Contains(t, script, "bridge-nonce")
97+}
M internal/infrastructure/cef/engine.go +51 -24
@@ -49,8 +49,9 @@ type Engine struct {
4949 alreadyRunningAppRelaunchHandler func(string)
5050
5151 // activeWebViews tracks all live webviews for CSS broadcast.
52- activeWebViews sync.Map // map[port.WebViewID]*WebView
53- activeCount atomic.Int32
52+ activeWebViews sync.Map // map[port.WebViewID]*WebView
53+ browserWebViews sync.Map // map[int32]*WebView
54+ activeCount atomic.Int32
5455
5556 // shutdownNotify is signaled when the active webview count reaches 0
5657 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
@@ -145,10 +146,46 @@ func (e *Engine) registerWebView(wv *WebView) {
145146 e.activeCount.Add(1)
146147 }
147148
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+
161+func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
162+ if e == nil || browser == nil || wv == nil {
163+ return
164+ }
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)
171+}
172+
173+func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
174+ if e == nil || browserID <= 0 {
175+ return
176+ }
177+ if current, ok := e.browserWebViews.Load(browserID); ok {
178+ if existing, ok := current.(*WebView); ok && existing == wv {
179+ e.browserWebViews.Delete(browserID)
180+ }
181+ }
182+}
183+
148184 // unregisterWebView removes a webview from the active tracking map.
149185 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
150-func (e *Engine) unregisterWebView(wv *WebView) {
186+func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
151187 e.activeWebViews.Delete(wv.id)
188+ e.unbindBrowserWebView(browserID, wv)
152189 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
153190 select {
154191 case e.shutdownNotify <- struct{}{}:
@@ -432,28 +469,18 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
432469 return false
433470 }
434471
435- browserID := browser.GetIdentifier()
436- valid := false
437- e.activeWebViews.Range(func(_, value any) bool {
438- wv, ok := value.(*WebView)
439- if !ok || wv == nil {
440- return true
441- }
472+ wv := e.webViewForBrowser(browser)
473+ if wv == nil {
474+ return false
475+ }
442476
443- wv.mu.RLock()
444- wvBrowser := wv.browser
445- wvBridgeNonce := wv.bridgeNonce
446- wv.mu.RUnlock()
447- if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {
448- return true
449- }
450- if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
451- valid = true
452- return false
453- }
454- return true
455- })
456- return valid
477+ wv.mu.RLock()
478+ wvBridgeNonce := wv.bridgeNonce
479+ wv.mu.RUnlock()
480+ if wvBridgeNonce == "" {
481+ return false
482+ }
483+ return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1
457484 }
458485
459486 func (e *Engine) currentDownloadHandler() *downloadHandler {
M internal/infrastructure/cef/engine_init.go +100 -59
@@ -179,51 +179,80 @@ func wireEngine(
179179 return nil, fmt.Errorf("GL loader: %w", err)
180180 }
181181
182- scale := detectHiDPIScale(logger)
183-
184- // Initialize the GPU transcoder when enabled in the loaded app config.
185- var mediaTranscoder port.MediaTranscoder
186- transcoderState := buildTranscoderStartupState(transcodingCfg)
187- if transcodingCfg.Enabled {
188- transcoderState.ProbeAttempted = true
189- tc := transcoderpkg.New(config.TranscodingConfig{
190- Enabled: transcodingCfg.Enabled,
191- HWAccel: transcodingCfg.HWAccel,
192- MaxConcurrent: transcodingCfg.MaxConcurrent,
193- Quality: transcodingCfg.Quality,
194- }, logger)
195- caps := tc.Capabilities()
196- transcoderState.API = caps.API
197- transcoderState.Encoders = append([]string(nil), caps.Encoders...)
198- transcoderState.Decoders = append([]string(nil), caps.Decoders...)
199- if tc.Available() {
200- mediaTranscoder = tc
201- transcoderState.Status = "available"
202- logger.Info().
203- Str("api", tc.Capabilities().API).
204- Strs("encoders", tc.Capabilities().Encoders).
205- Msg("cef: GPU transcoding available")
206- } else {
207- transcoderState.Status = "unavailable_no_compatible_gpu"
208- logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
209- }
210- }
211-
212- factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
213- scale: scale,
182+ mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger)
183+ eng.gl = gl
184+ eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{
185+ scale: detectHiDPIScale(logger),
214186 windowlessFrameRate: windowlessFrameRate,
215187 transcoder: mediaTranscoder,
216188 mediaClassifier: mediaClassifier,
217189 audioOutputFactory: audioFactory,
218190 })
219- pool := newWebViewPool(factory)
220-
221- eng.gl = gl
222- eng.factory = factory
223- eng.pool = pool
191+ eng.pool = newWebViewPool(eng.factory)
224192 eng.contentInj = newContentInjector(eng, nil)
225193 eng.transcoderState = transcoderState
226194
195+ messageRouter, schemeHandler, err := newEngineSchemeHandler(
196+ ctx,
197+ eng,
198+ logger,
199+ mediaTranscoder,
200+ currentConfigPayload,
201+ defaultConfigPayload,
202+ )
203+ if err != nil {
204+ purecef.Shutdown()
205+ return nil, err
206+ }
207+ eng.messageRouter = messageRouter
208+ eng.schemeHandler = schemeHandler
209+
210+ registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
211+ return eng, nil
212+}
213+
214+func initializeEngineTranscoder(
215+ transcodingCfg TranscodingRuntimeConfig,
216+ logger *zerolog.Logger,
217+) (port.MediaTranscoder, transcoderStartupState) {
218+ transcoderState := buildTranscoderStartupState(transcodingCfg)
219+ if !transcodingCfg.Enabled {
220+ return nil, transcoderState
221+ }
222+
223+ transcoderState.ProbeAttempted = true
224+ tc := transcoderpkg.New(config.TranscodingConfig{
225+ Enabled: transcodingCfg.Enabled,
226+ HWAccel: transcodingCfg.HWAccel,
227+ MaxConcurrent: transcodingCfg.MaxConcurrent,
228+ Quality: transcodingCfg.Quality,
229+ }, logger)
230+ caps := tc.Capabilities()
231+ transcoderState.API = caps.API
232+ transcoderState.Encoders = append([]string(nil), caps.Encoders...)
233+ transcoderState.Decoders = append([]string(nil), caps.Decoders...)
234+ if !tc.Available() {
235+ transcoderState.Status = "unavailable_no_compatible_gpu"
236+ logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
237+ return nil, transcoderState
238+ }
239+
240+ transcoderState.Status = "available"
241+ logger.Info().
242+ Str("api", caps.API).
243+ Strs("encoders", caps.Encoders).
244+ Msg("cef: GPU transcoding available")
245+ return tc, transcoderState
246+}
247+
248+func newEngineSchemeHandler(
249+ ctx context.Context,
250+ eng *Engine,
251+ logger *zerolog.Logger,
252+ mediaTranscoder port.MediaTranscoder,
253+ currentConfigPayload func() ([]byte, error),
254+ defaultConfigPayload func() ([]byte, error),
255+) (*MessageRouter, *dumbSchemeHandler, error) {
227256 messageRouter := NewMessageRouter(ctx)
228257 schemeHandler, err := newDumbSchemeHandler(
229258 ctx,
@@ -233,15 +262,25 @@ func wireEngine(
233262 defaultConfigPayload,
234263 )
235264 if err != nil {
236- purecef.Shutdown()
237- return nil, err
265+ return nil, nil, err
238266 }
239267 schemeHandler.setAssets(assets.WebUIAssets)
268+ schemeHandler.onClipboardSet = makeGTKClipboardSetter(logger)
269+ schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
270+ schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
271+ schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
272+ schemeHandler.onPopupClose = eng.handlePopupBridgeClose
273+ schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
274+ schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
275+ schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
276+ return messageRouter, schemeHandler, nil
277+}
240278
279+func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) {
241280 // Bridge clipboard writes from page JS → GDK system clipboard.
242281 // The callback is invoked on the CEF IO thread, so schedule the actual GDK
243282 // write on the GTK main loop.
244- schemeHandler.onClipboardSet = func(text string) {
283+ return func(text string) {
245284 fn := glib.SourceOnceFunc(func(_ uintptr) {
246285 if display := gdk.DisplayGetDefault(); display != nil {
247286 if cb := display.GetClipboard(); cb != nil {
@@ -256,35 +295,37 @@ func wireEngine(
256295 })
257296 glib.IdleAddOnce(&fn, 0)
258297 }
298+}
259299
260- schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261- schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
262-
263- schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
300+func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) {
301+ registerEngineSchemeFactory(logger, "dumb", "", schemeFactory)
302+ registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory)
303+}
264304
265- result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
266- if result != 1 {
267- logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
268- } else {
305+func registerEngineSchemeFactory(
306+ logger *zerolog.Logger,
307+ scheme, host string,
308+ schemeFactory purecef.SchemeHandlerFactory,
309+) {
310+ result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory)
311+ if scheme == "dumb" && host == "" {
312+ if result != 1 {
313+ logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
314+ return
315+ }
269316 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
317+ return
270318 }
271-
272- result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
273319 if result != 1 {
274320 logger.Error().
275321 Int32("result", result).
276322 Str("origin", actualInternalOrigin).
277323 Msg("cef: failed to register internal https handler factory")
278- } else {
279- logger.Info().
280- Str("origin", actualInternalOrigin).
281- Msg("cef: registered internal https handler factory")
324+ return
282325 }
283-
284- eng.messageRouter = messageRouter
285- eng.schemeHandler = schemeHandler
286-
287- return eng, nil
326+ logger.Info().
327+ Str("origin", actualInternalOrigin).
328+ Msg("cef: registered internal https handler factory")
288329 }
289330
290331 func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState {
A internal/infrastructure/cef/engine_popup_bridge.go +89 -0
@@ -0,0 +1,89 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
10+ if e == nil || browser == nil {
11+ return nil
12+ }
13+
14+ browserID := browser.GetIdentifier()
15+ if current, ok := e.browserWebViews.Load(browserID); ok {
16+ if wv, ok := current.(*WebView); ok && wv != nil {
17+ wv.mu.RLock()
18+ wvBrowser := wv.browser
19+ wv.mu.RUnlock()
20+ if wvBrowser != nil && wvBrowser.GetIdentifier() == browserID {
21+ return wv
22+ }
23+ e.unbindBrowserWebView(browserID, wv)
24+ }
25+ }
26+
27+ var matched *WebView
28+ e.activeWebViews.Range(func(_, value any) bool {
29+ wv, ok := value.(*WebView)
30+ if !ok || wv == nil {
31+ return true
32+ }
33+ wv.mu.RLock()
34+ wvBrowser := wv.browser
35+ wv.mu.RUnlock()
36+ if wvBrowser == nil || wvBrowser.GetIdentifier() != browserID {
37+ return true
38+ }
39+ matched = wv
40+ e.bindBrowserWebView(browser, wv)
41+ return false
42+ })
43+ return matched
44+}
45+
46+func (e *Engine) withBridgeSourceWebView(browser purecef.Browser, proxyID, action string, fn func(*WebView)) {
47+ if e == nil || browser == nil {
48+ return
49+ }
50+ wv := e.webViewForBrowser(browser)
51+ if wv == nil {
52+ logging.FromContext(e.currentContext()).Warn().
53+ Int32("browser_id", browser.GetIdentifier()).
54+ Str("proxy_id", proxyID).
55+ Msg("cef: " + action + " bridge could not resolve source webview")
56+ return
57+ }
58+ fn(wv)
59+}
60+
61+func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
62+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-open", func(wv *WebView) {
63+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
64+ })
65+}
66+
67+func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
68+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-navigate", func(wv *WebView) {
69+ wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
70+ })
71+}
72+
73+func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {
74+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-close", func(wv *WebView) {
75+ wv.handleSyntheticPopupClose(payload.ProxyID)
76+ })
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 +185 -90
@@ -2,7 +2,9 @@ package cef
22
33 import (
44 "context"
5+ "fmt"
56 "sync/atomic"
7+ "time"
68
79 purecef "github.com/bnema/purego-cef/cef"
810
@@ -41,6 +43,13 @@ type resizeNotifiableBrowserHost interface {
4143 Invalidate(purecef.PaintElementType)
4244 }
4345
46+var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser
47+
48+const (
49+ pendingBrowserCreateRetryDelay = 10 * time.Millisecond
50+ pendingBrowserCreateMaxPostRetries = 3
51+)
52+
4453 // newWebViewFactory returns a factory that will create WebViews using the
4554 // given GL loader and HiDPI scale factor.
4655 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -72,17 +81,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7281 // asynchronously; the returned WebView is usable immediately but navigation
7382 // will fail with errNoBrowser until OnAfterCreated fires.
7483 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
75- id := port.WebViewID(f.nextID.Add(1))
84+ wv := f.newWebView(ctx)
85+
86+ // Configure WindowInfo for off-screen rendering (OSR).
87+ windowInfo := purecef.NewWindowInfo()
88+ windowInfo.WindowlessRenderingEnabled = 1
89+ if externalBeginFrameEnabled() {
90+ windowInfo.ExternalBeginFrameEnabled = 1
91+ }
92+
93+ // Configure BrowserSettings.
94+ settings := purecef.NewBrowserSettings()
95+ settings.WindowlessFrameRate = f.windowlessFrameRate
96+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
97+ if bg := f.bgColor.Load(); bg != 0 {
98+ settings.BackgroundColor = bg
99+ }
100+
101+ // Defer browser creation until the GL area has a non-zero size.
102+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
103+ // is not yet realized at this point.
104+ f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings)
76105
106+ return wv, nil
107+}
108+
109+func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
110+ id := port.WebViewID(f.nextID.Add(1))
77111 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)
78112
79113 wv := &WebView{
80- id: id,
81- ctx: ctx,
82- engine: f.engine,
83- pipeline: pipeline,
84- audioOutputFactory: f.audioOutputFactory,
85- resizeReconciler: newResizeReconciler(ctx, id),
114+ id: id,
115+ ctx: ctx,
116+ engine: f.engine,
117+ pipeline: pipeline,
118+ audioOutputFactory: f.audioOutputFactory,
119+ resizeReconciler: newResizeReconciler(ctx, id),
120+ windowlessFrameRate: f.windowlessFrameRate,
121+ backgroundColor: f.bgColor.Load(),
86122 }
87123
88124 var transcodingHandler purecef.ResourceRequestHandler
@@ -129,30 +165,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
129165
130166 // Build a CEF client backed by our handlerSet.
131167 // 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
168+ wv.client = purecef.NewClient(wv.handlers)
169+ return wv
156170 }
157171
158172 func (f *WebViewFactory) configureInitialBrowserCreation(
@@ -166,60 +180,8 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
166180 }
167181
168182 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- }
183+ logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
184+ f.postPendingBrowserCreate(ctx, wv, w, h)
223185 }
224186
225187 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
@@ -268,6 +230,88 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
268230 }
269231 }
270232
233+func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
234+ log := logging.FromContext(ctx)
235+ pc := wv.takePendingCreate()
236+ if pc == nil {
237+ return
238+ }
239+
240+ task := cefNewTask(cefTaskFunc(func() {
241+ if wv.destroyed.Load() {
242+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
243+ return
244+ }
245+ pendingURL := wv.pendingNavigationURI()
246+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
247+ // the target URL can race host visibility/focus setup and strand the
248+ // renderer without an initial paint. We replay pending navigation from
249+ // OnAfterCreated once the host is fully wired.
250+ initialURL := "about:blank"
251+ result := cefBrowserHostCreateBrowser(
252+ pc.windowInfo,
253+ pc.client,
254+ initialURL,
255+ pc.settings,
256+ pc.extraInfo,
257+ nil, // requestContext
258+ )
259+ log.Debug().
260+ Str("initial_url", initialURL).
261+ Str("pending_url", pendingURL).
262+ Msg("cef: BrowserHostCreateBrowser initial URL")
263+ if f.engine != nil {
264+ f.engine.recordBrowserCreateRequest(w, h, result)
265+ }
266+ log.Debug().
267+ Int32("result", result).
268+ Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
269+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
270+ Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
271+ Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
272+ Bool("client_nil", pc.client == nil).
273+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
274+ }))
275+
276+ postResult := cefPostTask(purecef.ThreadIDTidUi, task)
277+ if postResult != 1 {
278+ pc.postTaskRetries++
279+ wv.mu.Lock()
280+ wv.pendingCreate = pc
281+ wv.mu.Unlock()
282+ log.Error().
283+ Int32("result", postResult).
284+ Int("retry", pc.postTaskRetries).
285+ Msg("cef: failed to post initial browser creation to CEF UI thread")
286+ f.schedulePendingBrowserCreateRetry(ctx, wv, w, h, pc)
287+ }
288+}
289+
290+func (f *WebViewFactory) schedulePendingBrowserCreateRetry(
291+ ctx context.Context,
292+ wv *WebView,
293+ w, h int32,
294+ pc *pendingBrowserCreate,
295+) {
296+ if f == nil || wv == nil || pc == nil || wv.destroyed.Load() {
297+ return
298+ }
299+ if pc.postTaskRetries > pendingBrowserCreateMaxPostRetries {
300+ logging.FromContext(ctx).Warn().
301+ Uint64("webview_id", uint64(wv.id)).
302+ Int("retries", pc.postTaskRetries).
303+ Int("max_retries", pendingBrowserCreateMaxPostRetries).
304+ Msg("cef: browser creation retries exhausted")
305+ return
306+ }
307+ cefScheduleAfter(pendingBrowserCreateRetryDelay, func() {
308+ if wv.destroyed.Load() {
309+ return
310+ }
311+ f.postPendingBrowserCreate(ctx, wv, w, h)
312+ })
313+}
314+
271315 func notifyBrowserResize(host resizeNotifiableBrowserHost) {
272316 if host == nil {
273317 return
@@ -276,9 +320,60 @@ func notifyBrowserResize(host resizeNotifiableBrowserHost) {
276320 host.Invalidate(purecef.PaintElementTypePetView)
277321 }
278322
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
323+// CreateRelated creates a popup shell for a native CEF popup. The popup
324+// browser itself is created by CEF from OnBeforePopup and later bound to this
325+// shell in OnAfterCreated.
326+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
327+ if f == nil || f.engine == nil {
328+ return nil, ErrRelatedWebViewUnsupported
329+ }
330+
331+ parent := f.engine.lookupWebView(parentID)
332+ if parent == nil {
333+ return nil, fmt.Errorf("parent webview %d not found", parentID)
334+ }
335+ if parent.destroyed.Load() {
336+ return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
337+ }
338+
339+ popupWV := f.newWebView(ctx)
340+ popupWV.markNativePopupCandidate(parent)
341+
342+ windowInfo := purecef.NewWindowInfo()
343+ windowInfo.WindowlessRenderingEnabled = 1
344+ if externalBeginFrameEnabled() {
345+ windowInfo.ExternalBeginFrameEnabled = 1
346+ }
347+
348+ settings := purecef.NewBrowserSettings()
349+ settings.WindowlessFrameRate = f.windowlessFrameRate
350+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
351+ if bg := f.bgColor.Load(); bg != 0 {
352+ settings.BackgroundColor = bg
353+ }
354+
355+ f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings)
356+ popupWV.pipeline.onFirstResize = func(w, h int32) {
357+ log := logging.FromContext(ctx)
358+ if popupWV.awaitsNativePopupAttachment() {
359+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")
360+ popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
361+ if !popupWV.startNativePopupFallback() {
362+ return
363+ }
364+ logging.FromContext(ctx).Warn().
365+ Uint64("webview_id", uint64(popupWV.id)).
366+ Msg("cef: native popup attach timed out, creating popup browser directly")
367+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
368+ })
369+ return
370+ }
371+
372+ if !popupWV.preparePopupShellDirectBrowserCreation() {
373+ return
374+ }
375+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
376+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
377+ }
378+ return popupWV, nil
284379 }
M internal/infrastructure/cef/factory_resize_test.go +70 -0
@@ -1,7 +1,9 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
6+ "time"
57
68 purecef "github.com/bnema/purego-cef/cef"
79 "github.com/stretchr/testify/require"
@@ -26,3 +28,71 @@ func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) {
2628
2729 require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls)
2830 }
31+
32+func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
33+ oldNewTask := cefNewTask
34+ oldPostTask := cefPostTask
35+ oldScheduleAfter := cefScheduleAfter
36+ oldCreateBrowser := cefBrowserHostCreateBrowser
37+ defer func() {
38+ cefNewTask = oldNewTask
39+ cefPostTask = oldPostTask
40+ cefScheduleAfter = oldScheduleAfter
41+ cefBrowserHostCreateBrowser = oldCreateBrowser
42+ }()
43+
44+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
45+
46+ postCalls := 0
47+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
48+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
49+ require.NotNil(t, task)
50+ postCalls++
51+ if postCalls == 1 {
52+ return 0
53+ }
54+ task.Execute()
55+ return 1
56+ }
57+
58+ retried := false
59+ cefScheduleAfter = func(delay time.Duration, fn func()) {
60+ require.Equal(t, pendingBrowserCreateRetryDelay, delay)
61+ retried = true
62+ fn()
63+ }
64+
65+ createCalls := 0
66+ cefBrowserHostCreateBrowser = func(
67+ windowInfo *purecef.WindowInfo,
68+ _ purecef.RawClient,
69+ url string,
70+ settings *purecef.BrowserSettings,
71+ _ purecef.DictionaryValue,
72+ _ purecef.RequestContext,
73+ ) int32 {
74+ createCalls++
75+ require.NotNil(t, windowInfo)
76+ require.NotNil(t, settings)
77+ require.Equal(t, "about:blank", url)
78+ return 1
79+ }
80+
81+ windowInfo := purecef.NewWindowInfo()
82+ settings := purecef.NewBrowserSettings()
83+ wv := &WebView{
84+ ctx: context.Background(),
85+ pendingCreate: &pendingBrowserCreate{
86+ windowInfo: &windowInfo,
87+ settings: &settings,
88+ },
89+ }
90+
91+ factory := &WebViewFactory{}
92+ factory.postPendingBrowserCreate(context.Background(), wv, 640, 480)
93+
94+ require.True(t, retried)
95+ require.Equal(t, 2, postCalls)
96+ require.Equal(t, 1, createCalls)
97+ require.Nil(t, wv.takePendingCreate())
98+}
A internal/infrastructure/cef/popup_opener_bridge.go +454 -0
@@ -0,0 +1,454 @@
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+const popupOpenerHTTPScheme = "http"
27+
28+func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) {
29+ if wv == nil {
30+ return
31+ }
32+ wv.mu.Lock()
33+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
34+ wv.mu.Unlock()
35+}
36+
37+// AddOpenerMessageCallback implements port.PopupOpenerCapable.
38+func (wv *WebView) AddOpenerMessageCallback(fn func()) {
39+ if wv == nil || fn == nil {
40+ return
41+ }
42+ wv.mu.Lock()
43+ wv.openerMessageCallbacks = append(wv.openerMessageCallbacks, fn)
44+ wv.mu.Unlock()
45+}
46+
47+// AddOpenerNavigationCallback implements port.PopupOpenerCapable.
48+func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {
49+ if wv == nil || fn == nil {
50+ return
51+ }
52+ wv.mu.Lock()
53+ wv.openerNavigationCallbacks = append(wv.openerNavigationCallbacks, fn)
54+ wv.mu.Unlock()
55+}
56+
57+func (wv *WebView) runOpenerMessageCallbacks() {
58+ if wv == nil {
59+ return
60+ }
61+ wv.mu.RLock()
62+ callbacks := append([]func(){}, wv.openerMessageCallbacks...)
63+ wv.mu.RUnlock()
64+ if len(callbacks) == 0 {
65+ return
66+ }
67+ wv.runOnGTK(func() {
68+ for _, fn := range callbacks {
69+ if fn != nil {
70+ fn()
71+ }
72+ }
73+ })
74+}
75+
76+func (wv *WebView) runOpenerNavigationCallbacks(uri string) {
77+ if wv == nil {
78+ return
79+ }
80+ wv.mu.RLock()
81+ callbacks := append([]func(string){}, wv.openerNavigationCallbacks...)
82+ wv.mu.RUnlock()
83+ if len(callbacks) == 0 {
84+ return
85+ }
86+ wv.runOnGTK(func() {
87+ for _, fn := range callbacks {
88+ if fn != nil {
89+ fn(uri)
90+ }
91+ }
92+ })
93+}
94+
95+// HasActivePopupOpenerBridge implements port.PopupOpenerCapable.
96+func (wv *WebView) HasActivePopupOpenerBridge() bool {
97+ _, active, _ := wv.popupOpenerBridgeState()
98+ return active
99+}
100+
101+func (wv *WebView) popupOpenerBridgeState() (parentURI string, active, blocked bool) {
102+ if wv == nil {
103+ return "", false, false
104+ }
105+ wv.mu.RLock()
106+ defer wv.mu.RUnlock()
107+ blocked = wv.popupNoJavaScriptAccess
108+ active = !blocked && wv.popupOpenerBridgeParent != nil
109+ parentURI = wv.popupOpenerBridgeParentURI
110+ return parentURI, active, blocked
111+}
112+
113+func (wv *WebView) ensureBridgeNonceLocked() string {
114+ if wv == nil {
115+ return ""
116+ }
117+ if wv.bridgeNonce != "" {
118+ return wv.bridgeNonce
119+ }
120+ nonce := newBridgeNonce()
121+ if nonce == "" {
122+ return ""
123+ }
124+ wv.bridgeNonce = nonce
125+ return nonce
126+}
127+
128+func (wv *WebView) syncPopupOpenerBridgeExtraInfoLocked() {
129+ if wv == nil || wv.pendingCreate == nil || wv.pendingCreate.windowInfo == nil {
130+ return
131+ }
132+ if wv.popupNoJavaScriptAccess || wv.popupOpenerBridgeParent == nil || wv.popupOpenerBridgeParentURI == "" {
133+ wv.pendingCreate.extraInfo = nil
134+ return
135+ }
136+ bridgeNonce := wv.ensureBridgeNonceLocked()
137+ if bridgeNonce == "" {
138+ wv.pendingCreate.extraInfo = nil
139+ return
140+ }
141+ wv.pendingCreate.extraInfo = popupOpenerRenderExtraInfoBuilder(wv.popupOpenerBridgeParentURI, bridgeNonce)
142+}
143+
144+// EnablePopupOpenerBridge implements port.PopupOpenerCapable.
145+func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
146+ if wv == nil {
147+ return
148+ }
149+ wv.mu.Lock()
150+ defer wv.mu.Unlock()
151+ wv.popupNoJavaScriptAccess = noJavaScriptAccess
152+ if noJavaScriptAccess {
153+ wv.popupOpenerBridgeParent = nil
154+ wv.popupOpenerBridgeParentURI = ""
155+ wv.syncPopupOpenerBridgeExtraInfoLocked()
156+ if wv.ctx != nil {
157+ logging.FromContext(wv.ctx).Debug().
158+ Uint64("webview_id", uint64(wv.id)).
159+ Bool("no_javascript_access", true).
160+ Msg("cef: popup opener bridge disabled")
161+ }
162+ return
163+ }
164+ parentWV, ok := parent.(*WebView)
165+ if !ok || parentWV == nil {
166+ wv.popupOpenerBridgeParent = nil
167+ wv.popupOpenerBridgeParentURI = ""
168+ wv.syncPopupOpenerBridgeExtraInfoLocked()
169+ if wv.ctx != nil {
170+ logging.FromContext(wv.ctx).Debug().
171+ Uint64("webview_id", uint64(wv.id)).
172+ Msg("cef: popup opener bridge unavailable: parent missing")
173+ }
174+ return
175+ }
176+ wv.popupOpenerBridgeParent = parentWV
177+ wv.popupOpenerBridgeParentURI = parentWV.URI()
178+ wv.syncPopupOpenerBridgeExtraInfoLocked()
179+ if wv.ctx != nil {
180+ logging.FromContext(wv.ctx).Debug().
181+ Uint64("webview_id", uint64(wv.id)).
182+ Uint64("parent_webview_id", uint64(parentWV.id)).
183+ Str("parent_uri", logging.TruncateURL(wv.popupOpenerBridgeParentURI, logging.PermissionLogURLMaxLen)).
184+ Msg("cef: popup opener bridge enabled")
185+ }
186+}
187+
188+func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string {
189+ if wv == nil || bridgeNonce == "" {
190+ return ""
191+ }
192+
193+ wv.mu.RLock()
194+ parentURI := wv.popupOpenerBridgeParentURI
195+ parent := wv.popupOpenerBridgeParent
196+ noJavaScriptAccess := wv.popupNoJavaScriptAccess
197+ blocked := noJavaScriptAccess || parent == nil
198+ wv.mu.RUnlock()
199+ if blocked {
200+ if wv.ctx != nil {
201+ logging.FromContext(wv.ctx).Debug().
202+ Uint64("webview_id", uint64(wv.id)).
203+ Bool("blocked", true).
204+ Bool("has_parent", parent != nil).
205+ Bool("no_javascript_access", noJavaScriptAccess).
206+ Msg("cef: popup opener bridge script skipped")
207+ }
208+ return ""
209+ }
210+ if wv.ctx != nil {
211+ logging.FromContext(wv.ctx).Debug().
212+ Uint64("webview_id", uint64(wv.id)).
213+ Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
214+ Msg("cef: popup opener bridge script prepared")
215+ }
216+
217+ return buildPopupOpenerBridgeJS(bridgeNonce, parentURI)
218+}
219+
220+func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {
221+ if wv == nil {
222+ return
223+ }
224+
225+ trimmedURL := strings.TrimSpace(targetURL)
226+ if trimmedURL == "" {
227+ return
228+ }
229+
230+ wv.mu.RLock()
231+ opener := wv.popupOpenerBridgeParent
232+ wv.mu.RUnlock()
233+ if opener == nil || opener.destroyed.Load() {
234+ if wv.ctx != nil {
235+ logging.FromContext(wv.ctx).Debug().
236+ Uint64("webview_id", uint64(wv.id)).
237+ Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)).
238+ Msg("cef: popup opener navigate ignored: opener unavailable")
239+ }
240+ return
241+ }
242+
243+ resolvedURL := resolvePopupOpenerNavigationTarget(trimmedURL, opener.URI())
244+ if resolvedURL == "" {
245+ if wv.ctx != nil {
246+ logging.FromContext(wv.ctx).Warn().
247+ Uint64("webview_id", uint64(wv.id)).
248+ Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)).
249+ Msg("cef: popup opener navigate rejected")
250+ }
251+ return
252+ }
253+ if wv.ctx != nil {
254+ logging.FromContext(wv.ctx).Debug().
255+ Uint64("webview_id", uint64(wv.id)).
256+ Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)).
257+ Str("resolved_url", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)).
258+ Msg("cef: popup opener navigate received")
259+ }
260+
261+ if err := opener.LoadURI(context.Background(), resolvedURL); err != nil && opener.ctx != nil {
262+ logging.FromContext(opener.ctx).Warn().
263+ Err(err).
264+ Str("uri", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)).
265+ Msg("cef: failed to navigate synthetic popup opener")
266+ }
267+
268+ wv.mu.Lock()
269+ wv.popupOpenerBridgeParentURI = resolvedURL
270+ wv.mu.Unlock()
271+ wv.runOpenerNavigationCallbacks(resolvedURL)
272+}
273+
274+func resolvePopupOpenerNavigationTarget(rawTarget, openerURI string) string {
275+ trimmedTarget := strings.TrimSpace(rawTarget)
276+ if trimmedTarget == "" {
277+ return ""
278+ }
279+ parsedTarget, err := neturl.Parse(trimmedTarget)
280+ if err == nil && parsedTarget.IsAbs() {
281+ return sanitizePopupOpenerNavigationTarget(parsedTarget)
282+ }
283+ if strings.HasPrefix(trimmedTarget, "//") {
284+ base, baseErr := neturl.Parse(strings.TrimSpace(openerURI))
285+ if baseErr == nil && base != nil && base.Scheme != "" {
286+ parsedTarget.Scheme = base.Scheme
287+ return sanitizePopupOpenerNavigationTarget(parsedTarget)
288+ }
289+ return ""
290+ }
291+ base, err := neturl.Parse(strings.TrimSpace(openerURI))
292+ if err != nil || base == nil || base.Scheme == "" || base.Host == "" {
293+ return ""
294+ }
295+ ref, err := neturl.Parse(trimmedTarget)
296+ if err != nil {
297+ return ""
298+ }
299+ return sanitizePopupOpenerNavigationTarget(base.ResolveReference(ref))
300+}
301+
302+func sanitizePopupOpenerNavigationTarget(target *neturl.URL) string {
303+ if target == nil {
304+ return ""
305+ }
306+ switch scheme := strings.ToLower(strings.TrimSpace(target.Scheme)); scheme {
307+ case popupOpenerHTTPScheme, actualInternalScheme:
308+ return target.String()
309+ default:
310+ return ""
311+ }
312+}
313+
314+func (wv *WebView) handlePopupOpenerPostMessage(payload popupOpenerPostMessagePayload) {
315+ if wv == nil {
316+ return
317+ }
318+
319+ wv.mu.RLock()
320+ opener := wv.popupOpenerBridgeParent
321+ wv.mu.RUnlock()
322+ if opener == nil || opener.destroyed.Load() {
323+ if wv.ctx != nil {
324+ logging.FromContext(wv.ctx).Debug().
325+ Uint64("webview_id", uint64(wv.id)).
326+ Str("target_origin", payload.TargetOrigin).
327+ Msg("cef: popup opener postMessage ignored: opener unavailable")
328+ }
329+ return
330+ }
331+ if !targetOriginMatchesPopupOpener(payload.TargetOrigin, opener.URI()) {
332+ if wv.ctx != nil {
333+ logging.FromContext(wv.ctx).Warn().
334+ Uint64("webview_id", uint64(wv.id)).
335+ Str("target_origin", payload.TargetOrigin).
336+ Str("opener_uri", logging.TruncateURL(opener.URI(), logging.PermissionLogURLMaxLen)).
337+ Msg("cef: popup opener postMessage rejected")
338+ }
339+ return
340+ }
341+ sourceOrigin, sourceHref := popupOpenerSourceMetadata(wv)
342+ if wv.ctx != nil {
343+ logging.FromContext(wv.ctx).Debug().
344+ Uint64("webview_id", uint64(wv.id)).
345+ Str("target_origin", payload.TargetOrigin).
346+ Str("source_origin", sourceOrigin).
347+ Str("source_href", logging.TruncateURL(sourceHref, logging.PermissionLogURLMaxLen)).
348+ Msg("cef: popup opener postMessage received")
349+ }
350+
351+ var dataExpr string
352+ switch strings.ToLower(strings.TrimSpace(payload.DataKind)) {
353+ case "json":
354+ dataExpr = fmt.Sprintf("JSON.parse('%s')", webutil.EscapeForJSString(payload.Data))
355+ case "string":
356+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
357+ default:
358+ dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data))
359+ }
360+
361+ script := fmt.Sprintf(`(function() {
362+ try {
363+ var data;
364+ try {
365+ data = %s;
366+ } catch (_) {
367+ data = undefined;
368+ }
369+ var event = new MessageEvent('message', {
370+ data: data,
371+ origin: '%s',
372+ source: null
373+ });
374+ var sourceProxy = {
375+ closed: false,
376+ close: function() { return undefined; },
377+ focus: function() { return undefined; },
378+ postMessage: function() { return undefined; },
379+ location: {
380+ href: '%s',
381+ toString: function() { return this.href; }
382+ }
383+ };
384+ try {
385+ Object.defineProperty(event, 'source', {
386+ configurable: true,
387+ enumerable: true,
388+ value: sourceProxy
389+ });
390+ } catch (_) {}
391+ window.dispatchEvent(event);
392+ } catch (_) {}
393+})();`, dataExpr, webutil.EscapeForJSString(sourceOrigin), webutil.EscapeForJSString(sourceHref))
394+ opener.RunJavaScript(context.Background(), script)
395+ wv.runOpenerMessageCallbacks()
396+}
397+
398+func targetOriginMatchesPopupOpener(targetOrigin, openerURI string) bool {
399+ trimmedTarget := strings.TrimSpace(targetOrigin)
400+ if trimmedTarget == "" || trimmedTarget == "*" {
401+ return true
402+ }
403+ targetCanonical := canonicalOrigin(trimmedTarget)
404+ return targetCanonical != "" && targetCanonical == canonicalOrigin(openerURI)
405+}
406+
407+func popupOpenerSourceMetadata(wv *WebView) (origin, href string) {
408+ if wv == nil {
409+ return "", ""
410+ }
411+ href = strings.TrimSpace(wv.URI())
412+ if href == "" {
413+ href = strings.TrimSpace(wv.pendingNavigationURI())
414+ }
415+ if href == "" {
416+ return "", ""
417+ }
418+ return originFromURL(href), href
419+}
420+
421+func originFromURL(rawURL string) string {
422+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
423+ parsed, err := neturl.Parse(canonicalURL)
424+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
425+ return ""
426+ }
427+ return parsed.Scheme + "://" + parsed.Host
428+}
429+
430+func canonicalOrigin(rawURL string) string {
431+ canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL))
432+ parsed, err := neturl.Parse(canonicalURL)
433+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
434+ return ""
435+ }
436+ scheme := strings.ToLower(parsed.Scheme)
437+ host := strings.ToLower(parsed.Hostname())
438+ if host == "" {
439+ return ""
440+ }
441+ portStr := parsed.Port()
442+ if portStr == "" {
443+ switch scheme {
444+ case popupOpenerHTTPScheme:
445+ portStr = "80"
446+ case actualInternalScheme:
447+ portStr = "443"
448+ }
449+ }
450+ if portStr == "" {
451+ return scheme + "://" + host
452+ }
453+ return scheme + "://" + host + ":" + portStr
454+}
A internal/infrastructure/cef/popup_opener_bridge_test.go +71 -0
@@ -0,0 +1,71 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestResolvePopupOpenerNavigationTarget_ResolvesRelativeAgainstOpener(t *testing.T) {
14+ require.Equal(
15+ t,
16+ "https://example.com/oauth/callback",
17+ resolvePopupOpenerNavigationTarget("callback", "https://example.com/oauth/start"),
18+ )
19+}
20+
21+func TestResolvePopupOpenerNavigationTarget_PreservesAbsoluteTarget(t *testing.T) {
22+ require.Equal(
23+ t,
24+ "https://accounts.example.com/finish",
25+ resolvePopupOpenerNavigationTarget("https://accounts.example.com/finish", "https://example.com/oauth/start"),
26+ )
27+}
28+
29+func TestResolvePopupOpenerNavigationTarget_RejectsNonHTTPAbsoluteTarget(t *testing.T) {
30+ require.Empty(t, resolvePopupOpenerNavigationTarget("javascript:alert(1)", "https://example.com/oauth/start"))
31+ require.Empty(t, resolvePopupOpenerNavigationTarget("data:text/html,boom", "https://example.com/oauth/start"))
32+ require.Empty(t, resolvePopupOpenerNavigationTarget("file:///tmp/boom", "https://example.com/oauth/start"))
33+}
34+
35+func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
36+ require.Equal(t, "https://example.com:8443", originFromURL("https://example.com:8443/callback"))
37+}
38+
39+func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
40+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))
41+}
42+
43+func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
44+ require.True(t, targetOriginMatchesPopupOpener("https://example.com:443", "https://example.com/callback"))
45+}
46+
47+func TestHandlePopupOpenerPostMessage_UsesPopupCommittedURIForSourceMetadata(t *testing.T) {
48+ frame := cefmocks.NewMockFrame(t)
49+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
50+ return strings.Contains(script, "https://popup.example") &&
51+ strings.Contains(script, "https://popup.example/oauth/callback") &&
52+ !strings.Contains(script, "https://evil.example")
53+ }), "", int32(0)).Once()
54+ browser := cefmocks.NewMockBrowser(t)
55+ browser.EXPECT().GetMainFrame().Return(frame).Once()
56+
57+ opener := &WebView{ctx: context.Background(), browser: browser, uri: "https://app.example/session"}
58+ popup := &WebView{
59+ ctx: context.Background(),
60+ uri: "https://popup.example/oauth/callback",
61+ popupOpenerBridgeParent: opener,
62+ }
63+
64+ popup.handlePopupOpenerPostMessage(popupOpenerPostMessagePayload{
65+ Data: `{"ok":true}`,
66+ DataKind: "json",
67+ TargetOrigin: "https://app.example",
68+ SourceOrigin: "https://evil.example",
69+ SourceHref: "https://evil.example/fake",
70+ })
71+}
A internal/infrastructure/cef/popup_opener_renderer.go +142 -0
@@ -0,0 +1,142 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const (
13+ popupOpenerExtraInfoEnabledKey = "dumber.popup_opener.enabled"
14+ popupOpenerExtraInfoParentURIKey = "dumber.popup_opener.parent_uri"
15+ popupOpenerExtraInfoBridgeNonceKey = "dumber.popup_opener.bridge_nonce"
16+ popupOpenerRenderScriptURL = "dumber://popup-opener-renderer"
17+)
18+
19+type popupOpenerRenderMetadata struct {
20+ ParentURI string
21+ BridgeNonce string
22+}
23+
24+var popupOpenerRenderExtraInfoBuilder = buildPopupOpenerRenderExtraInfo
25+
26+func buildPopupOpenerRenderExtraInfo(parentURI, bridgeNonce string) purecef.DictionaryValue {
27+ if parentURI == "" || bridgeNonce == "" {
28+ return nil
29+ }
30+ extraInfo := purecef.DictionaryValueCreate()
31+ if extraInfo == nil {
32+ return nil
33+ }
34+ extraInfo.SetBool(popupOpenerExtraInfoEnabledKey, 1)
35+ extraInfo.SetString(popupOpenerExtraInfoParentURIKey, parentURI)
36+ extraInfo.SetString(popupOpenerExtraInfoBridgeNonceKey, bridgeNonce)
37+ return extraInfo
38+}
39+
40+func decodePopupOpenerRenderMetadata(extraInfo purecef.DictionaryValue) (popupOpenerRenderMetadata, bool) {
41+ var metadata popupOpenerRenderMetadata
42+ if extraInfo == nil || extraInfo.GetBool(popupOpenerExtraInfoEnabledKey) != 1 {
43+ return metadata, false
44+ }
45+ metadata.ParentURI = extraInfo.GetString(popupOpenerExtraInfoParentURIKey)
46+ metadata.BridgeNonce = extraInfo.GetString(popupOpenerExtraInfoBridgeNonceKey)
47+ if metadata.ParentURI == "" || metadata.BridgeNonce == "" {
48+ return popupOpenerRenderMetadata{}, false
49+ }
50+ return metadata, true
51+}
52+
53+type popupOpenerRenderProcessHandler struct {
54+ mu sync.RWMutex
55+ byBrowserID map[int32]popupOpenerRenderMetadata
56+}
57+
58+func newPopupOpenerRenderProcessHandler() *popupOpenerRenderProcessHandler {
59+ return &popupOpenerRenderProcessHandler{byBrowserID: make(map[int32]popupOpenerRenderMetadata)}
60+}
61+
62+func (*popupOpenerRenderProcessHandler) OnWebKitInitialized() {}
63+
64+func (h *popupOpenerRenderProcessHandler) OnBrowserCreated(browser purecef.Browser, extraInfo purecef.DictionaryValue) {
65+ if h == nil || browser == nil {
66+ return
67+ }
68+ metadata, ok := decodePopupOpenerRenderMetadata(extraInfo)
69+ if !ok {
70+ return
71+ }
72+ browserID := browser.GetIdentifier()
73+ h.mu.Lock()
74+ h.byBrowserID[browserID] = metadata
75+ h.mu.Unlock()
76+}
77+
78+func (h *popupOpenerRenderProcessHandler) OnBrowserDestroyed(browser purecef.Browser) {
79+ if h == nil || browser == nil {
80+ return
81+ }
82+ browserID := browser.GetIdentifier()
83+ h.mu.Lock()
84+ delete(h.byBrowserID, browserID)
85+ h.mu.Unlock()
86+}
87+
88+func (*popupOpenerRenderProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil }
89+
90+func (h *popupOpenerRenderProcessHandler) OnContextCreated(browser purecef.Browser, frame purecef.Frame, ctx purecef.V8Context) {
91+ if h == nil || browser == nil || frame == nil || !frame.IsMain() || ctx == nil || !ctx.IsValid() {
92+ return
93+ }
94+ browserID := browser.GetIdentifier()
95+ h.mu.RLock()
96+ metadata, ok := h.byBrowserID[browserID]
97+ h.mu.RUnlock()
98+ if !ok {
99+ return
100+ }
101+
102+ script := buildPopupOpenerBridgeJS(metadata.BridgeNonce, metadata.ParentURI)
103+ if script == "" {
104+ return
105+ }
106+ if ctx.Enter() != 1 {
107+ logging.FromContext(context.Background()).Warn().
108+ Int32("browser_id", browserID).
109+ Msg("cef: popup opener renderer bridge could not enter V8 context")
110+ return
111+ }
112+ defer ctx.Exit()
113+ if ctx.Eval(script, popupOpenerRenderScriptURL, 0, nil, nil) != 1 {
114+ logging.FromContext(context.Background()).Warn().
115+ Int32("browser_id", browserID).
116+ Msg("cef: popup opener renderer bridge eval failed")
117+ }
118+}
119+
120+func (*popupOpenerRenderProcessHandler) OnContextReleased(purecef.Browser, purecef.Frame, purecef.V8Context) {
121+}
122+
123+func (*popupOpenerRenderProcessHandler) OnUncaughtException(
124+ purecef.Browser,
125+ purecef.Frame,
126+ purecef.V8Context,
127+ purecef.V8Exception,
128+ purecef.V8StackTrace,
129+) {
130+}
131+
132+func (*popupOpenerRenderProcessHandler) OnFocusedNodeChanged(purecef.Browser, purecef.Frame, purecef.Domnode) {
133+}
134+
135+func (*popupOpenerRenderProcessHandler) OnProcessMessageReceived(
136+ purecef.Browser,
137+ purecef.Frame,
138+ purecef.ProcessID,
139+ purecef.ProcessMessage,
140+) int32 {
141+ return 0
142+}
A internal/infrastructure/cef/popup_opener_renderer_test.go +160 -0
@@ -0,0 +1,160 @@
1+package cef
2+
3+import (
4+ "testing"
5+ "unsafe"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type testPopupOpenerDictionaryValue struct {
13+ strings map[string]string
14+ bools map[string]int32
15+}
16+
17+func (v testPopupOpenerDictionaryValue) IsValid() bool { return true }
18+func (v testPopupOpenerDictionaryValue) IsOwned() bool { return true }
19+func (v testPopupOpenerDictionaryValue) IsReadOnly() bool { return false }
20+func (v testPopupOpenerDictionaryValue) IsSame(purecef.DictionaryValue) bool { return false }
21+func (v testPopupOpenerDictionaryValue) IsEqual(purecef.DictionaryValue) bool { return false }
22+func (v testPopupOpenerDictionaryValue) Copy(int32) purecef.DictionaryValue { return v }
23+func (v testPopupOpenerDictionaryValue) GetSize() int { return len(v.strings) + len(v.bools) }
24+func (v testPopupOpenerDictionaryValue) Clear() int32 { return 1 }
25+func (v testPopupOpenerDictionaryValue) HasKey(key string) bool {
26+ _, ok := v.strings[key]
27+ if ok {
28+ return true
29+ }
30+ _, ok = v.bools[key]
31+ return ok
32+}
33+func (v testPopupOpenerDictionaryValue) GetKeys(purecef.StringList) int32 { return 0 }
34+func (v testPopupOpenerDictionaryValue) Remove(string) int32 { return 1 }
35+func (v testPopupOpenerDictionaryValue) GetType(string) purecef.ValueType { return 0 }
36+func (v testPopupOpenerDictionaryValue) GetValue(string) purecef.Value { return nil }
37+func (v testPopupOpenerDictionaryValue) GetBool(key string) int32 { return v.bools[key] }
38+func (v testPopupOpenerDictionaryValue) GetInt(string) int32 { return 0 }
39+func (v testPopupOpenerDictionaryValue) GetDouble(string) float64 { return 0 }
40+func (v testPopupOpenerDictionaryValue) GetString(key string) string { return v.strings[key] }
41+func (v testPopupOpenerDictionaryValue) GetBinary(string) purecef.BinaryValue { return nil }
42+func (v testPopupOpenerDictionaryValue) GetDictionary(string) purecef.DictionaryValue { return nil }
43+func (v testPopupOpenerDictionaryValue) GetList(string) purecef.ListValue { return nil }
44+func (v testPopupOpenerDictionaryValue) SetValue(string, purecef.Value) int32 { return 1 }
45+func (v testPopupOpenerDictionaryValue) SetNull(string) int32 { return 1 }
46+func (v testPopupOpenerDictionaryValue) SetBool(string, int32) int32 { return 1 }
47+func (v testPopupOpenerDictionaryValue) SetInt(string, int32) int32 { return 1 }
48+func (v testPopupOpenerDictionaryValue) SetDouble(string, float64) int32 { return 1 }
49+func (v testPopupOpenerDictionaryValue) SetString(string, string) int32 { return 1 }
50+func (v testPopupOpenerDictionaryValue) SetBinary(string, purecef.BinaryValue) int32 { return 1 }
51+func (v testPopupOpenerDictionaryValue) SetDictionary(string, purecef.DictionaryValue) int32 {
52+ return 1
53+}
54+func (v testPopupOpenerDictionaryValue) SetList(string, purecef.ListValue) int32 { return 1 }
55+
56+type testPopupOpenerV8Context struct {
57+ valid bool
58+ evalCode string
59+ evalURL string
60+ enterCalls int
61+ exitCalls int
62+}
63+
64+func (c *testPopupOpenerV8Context) GetTaskRunner() purecef.TaskRunner { return nil }
65+func (c *testPopupOpenerV8Context) IsValid() bool { return c.valid }
66+func (c *testPopupOpenerV8Context) GetBrowser() purecef.Browser { return nil }
67+func (c *testPopupOpenerV8Context) GetFrame() purecef.Frame { return nil }
68+func (c *testPopupOpenerV8Context) GetGlobal() purecef.V8Value { return nil }
69+func (c *testPopupOpenerV8Context) Enter() int32 {
70+ c.enterCalls++
71+ return 1
72+}
73+func (c *testPopupOpenerV8Context) Exit() int32 {
74+ c.exitCalls++
75+ return 1
76+}
77+func (c *testPopupOpenerV8Context) IsSame(that purecef.V8Context) bool { return c == that }
78+func (c *testPopupOpenerV8Context) Eval(code, scriptURL string, _ int32, _, _ unsafe.Pointer) int32 {
79+ c.evalCode = code
80+ c.evalURL = scriptURL
81+ return 1
82+}
83+func (c *testPopupOpenerV8Context) RawPointer() unsafe.Pointer { return nil }
84+func (c *testPopupOpenerV8Context) Release() {}
85+
86+func TestDecodePopupOpenerRenderMetadata(t *testing.T) {
87+ metadata, ok := decodePopupOpenerRenderMetadata(testPopupOpenerDictionaryValue{
88+ strings: map[string]string{
89+ popupOpenerExtraInfoParentURIKey: "https://example.com/login",
90+ popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce",
91+ },
92+ bools: map[string]int32{
93+ popupOpenerExtraInfoEnabledKey: 1,
94+ },
95+ })
96+
97+ require.True(t, ok)
98+ require.Equal(t, "https://example.com/login", metadata.ParentURI)
99+ require.Equal(t, "bridge-nonce", metadata.BridgeNonce)
100+}
101+
102+func TestPopupOpenerRenderProcessHandler_OnContextCreatedInstallsBridgeForConfiguredMainFrame(t *testing.T) {
103+ h := newPopupOpenerRenderProcessHandler()
104+ browser := cefmocks.NewMockBrowser(t)
105+ browser.EXPECT().GetIdentifier().Return(int32(42)).Twice()
106+ ctx := &testPopupOpenerV8Context{valid: true}
107+
108+ h.OnBrowserCreated(browser, testPopupOpenerDictionaryValue{
109+ strings: map[string]string{
110+ popupOpenerExtraInfoParentURIKey: "https://example.com/login",
111+ popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce",
112+ },
113+ bools: map[string]int32{
114+ popupOpenerExtraInfoEnabledKey: 1,
115+ },
116+ })
117+ h.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx)
118+
119+ require.Equal(t, 1, ctx.enterCalls)
120+ require.Equal(t, 1, ctx.exitCalls)
121+ require.Contains(t, ctx.evalCode, "popup-opener-navigate")
122+ require.Contains(t, ctx.evalCode, "popup-opener-post-message")
123+ require.Contains(t, ctx.evalCode, "https://example.com/login")
124+ require.Contains(t, ctx.evalCode, "bridge-nonce")
125+ require.Equal(t, popupOpenerRenderScriptURL, ctx.evalURL)
126+}
127+
128+func TestPopupOpenerRenderProcessHandler_OnContextCreatedSkipsWithoutMetadata(t *testing.T) {
129+ h := newPopupOpenerRenderProcessHandler()
130+ browser := cefmocks.NewMockBrowser(t)
131+ browser.EXPECT().GetIdentifier().Return(int32(7)).Once()
132+ ctx := &testPopupOpenerV8Context{valid: true}
133+
134+ h.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx)
135+
136+ require.Zero(t, ctx.enterCalls)
137+ require.Empty(t, ctx.evalCode)
138+}
139+
140+func TestEnablePopupOpenerBridge_SyncsPendingCreateExtraInfo(t *testing.T) {
141+ oldBuilder := popupOpenerRenderExtraInfoBuilder
142+ popupOpenerRenderExtraInfoBuilder = func(parentURI, bridgeNonce string) purecef.DictionaryValue {
143+ require.Equal(t, "https://example.com/login", parentURI)
144+ require.NotEmpty(t, bridgeNonce)
145+ return testPopupOpenerDictionaryValue{strings: map[string]string{"ok": "1"}}
146+ }
147+ t.Cleanup(func() {
148+ popupOpenerRenderExtraInfoBuilder = oldBuilder
149+ })
150+
151+ parent := &WebView{}
152+ parent.updateURI("https://example.com/login")
153+ windowInfo := purecef.NewWindowInfo()
154+ wv := &WebView{pendingCreate: &pendingBrowserCreate{windowInfo: &windowInfo}}
155+
156+ wv.EnablePopupOpenerBridge(parent, false)
157+
158+ require.NotNil(t, wv.pendingCreate.extraInfo)
159+ require.NotEmpty(t, wv.bridgeNonce)
160+}
M internal/infrastructure/cef/renderer_bridge.go +52 -195
@@ -17,207 +17,15 @@ const (
1717 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
1818 rendererBridgeActionFocusSync = "focus_sync"
1919 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionPopupOpen = "popup_open"
21+ rendererBridgeActionPopupNavigate = "popup_navigate"
22+ rendererBridgeActionPopupClose = "popup_close"
2023 rendererBridgeActionReady = "bridge_ready"
2124 rendererBridgeExtensionName = "dumber.renderer_bridge"
2225 )
2326
2427 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
2528
26-const rendererBridgeExtensionJS = `
27-(function() {
28- native function Dispatch(action, payload);
29-
30- function send(action, payload) {
31- return Dispatch(action, payload == null ? '' : String(payload));
32- }
33-
34- function isEditable(node) {
35- if (!node || node.nodeType !== 1) return false;
36- if (node.isContentEditable) return true;
37- var tag = node.tagName;
38- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
39- if (node.disabled || node.readOnly) return false;
40- return true;
41- }
42-
43- function getActiveElementSelection() {
44- var el = document.activeElement;
45- if (!el) return '';
46- var tag = el.tagName;
47- if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
48- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
49- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
50- if (end > start) return el.value.slice(start, end);
51- }
52- return '';
53- }
54-
55- function getSelectedText() {
56- var activeSelection = getActiveElementSelection();
57- if (activeSelection) return activeSelection;
58- var sel = window.getSelection();
59- return sel ? sel.toString() : '';
60- }
61-
62- function notifyExplicitTextCopy(action, text) {
63- var normalizedText = text == null ? '' : String(text);
64- var normalizedAction = action == null || action === '' ? 'copy' : String(action);
65- return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
66- }
67-
68- if (typeof document !== 'undefined' && document.addEventListener) {
69- document.addEventListener('focusin', function(event) {
70- if (isEditable(event.target)) {
71- send('focus_sync', '');
72- }
73- }, true);
74-
75- function mirrorClipboardEvent(action, e) {
76- if (!e.isTrusted) return;
77- var capturedText = '';
78- if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
79- try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
80- }
81- setTimeout(function() {
82- var text = capturedText;
83- if (!text && !e.defaultPrevented) text = getSelectedText();
84- if (text) notifyExplicitTextCopy(action, text);
85- }, 0);
86- }
87-
88- document.addEventListener('copy', function(e) {
89- mirrorClipboardEvent('copy', e);
90- }, true);
91- document.addEventListener('cut', function(e) {
92- mirrorClipboardEvent('cut', e);
93- }, true);
94- }
95- try {
96- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
97- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
98- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
99-
100- function mirrorClipboardItems(items) {
101- try {
102- if (!items || typeof items.length !== 'number') return;
103- Array.prototype.forEach.call(items, function(item) {
104- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
105- Promise.resolve(item.getType('text/plain'))
106- .then(function(blob) {
107- if (!blob || typeof blob.text !== 'function') return '';
108- return blob.text();
109- })
110- .then(function(text) {
111- if (text) notifyExplicitTextCopy('copy', text);
112- })
113- .catch(function() {});
114- });
115- } catch (_) {}
116- }
117-
118- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
119- clipboardProto.__dumberWriteTextPatched = true;
120- var originalProtoWriteText = clipboardProto.writeText;
121- var wrappedProtoWriteText = function(text) {
122- var normalized = text == null ? '' : String(text);
123- var result = originalProtoWriteText.call(this, normalized);
124- if (result && typeof result.then === 'function') {
125- return result.then(function(value) {
126- if (normalized) notifyExplicitTextCopy('copy', normalized);
127- return value;
128- });
129- }
130- if (normalized) notifyExplicitTextCopy('copy', normalized);
131- return result;
132- };
133- try {
134- Object.defineProperty(clipboardProto, 'writeText', {
135- configurable: true,
136- writable: true,
137- value: wrappedProtoWriteText
138- });
139- } catch (_) {
140- clipboardProto.writeText = wrappedProtoWriteText;
141- }
142- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
143- window.__dumberClipboardWriteTextPatched = true;
144- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
145- var wrappedWriteText = function(text) {
146- var normalized = text == null ? '' : String(text);
147- var result = originalWriteText(normalized);
148- if (result && typeof result.then === 'function') {
149- return result.then(function(value) {
150- if (normalized) notifyExplicitTextCopy('copy', normalized);
151- return value;
152- });
153- }
154- if (normalized) notifyExplicitTextCopy('copy', normalized);
155- return result;
156- };
157- try {
158- Object.defineProperty(clipboardObj, 'writeText', {
159- configurable: true,
160- writable: true,
161- value: wrappedWriteText
162- });
163- } catch (_) {
164- clipboardObj.writeText = wrappedWriteText;
165- }
166- }
167-
168- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
169- clipboardProto.__dumberWritePatched = true;
170- var originalProtoWrite = clipboardProto.write;
171- var wrappedProtoWrite = function(items) {
172- var result = originalProtoWrite.apply(this, arguments);
173- if (result && typeof result.then === 'function') {
174- return result.then(function(value) {
175- mirrorClipboardItems(items);
176- return value;
177- });
178- }
179- mirrorClipboardItems(items);
180- return result;
181- };
182- try {
183- Object.defineProperty(clipboardProto, 'write', {
184- configurable: true,
185- writable: true,
186- value: wrappedProtoWrite
187- });
188- } catch (_) {
189- clipboardProto.write = wrappedProtoWrite;
190- }
191- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
192- window.__dumberClipboardWritePatched = true;
193- var originalWrite = clipboardObj.write.bind(clipboardObj);
194- var wrappedWrite = function(items) {
195- var result = originalWrite(items);
196- if (result && typeof result.then === 'function') {
197- return result.then(function(value) {
198- mirrorClipboardItems(items);
199- return value;
200- });
201- }
202- mirrorClipboardItems(items);
203- return result;
204- };
205- try {
206- Object.defineProperty(clipboardObj, 'write', {
207- configurable: true,
208- writable: true,
209- value: wrappedWrite
210- });
211- } catch (_) {
212- clipboardObj.write = wrappedWrite;
213- }
214- }
215- } catch (_) {}
216-
217- send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
218-})();
219-`
220-
22129 type rendererBridgeProcessHandler struct {
22230 registerOnce sync.Once
22331 bridgeHandler purecef.V8Handler
@@ -338,6 +146,23 @@ type rendererBridgeExplicitTextCopyPayload struct {
338146 Action string `json:"action"`
339147 }
340148
149+type rendererBridgePopupOpenPayload struct {
150+ ProxyID string `json:"proxy_id"`
151+ URL string `json:"url"`
152+ FrameName string `json:"frame_name"`
153+ UserGesture bool `json:"user_gesture"`
154+ NoJavaScriptAccess bool `json:"no_javascript_access"`
155+}
156+
157+type rendererBridgePopupNavigatePayload struct {
158+ ProxyID string `json:"proxy_id"`
159+ URL string `json:"url"`
160+}
161+
162+type rendererBridgePopupClosePayload struct {
163+ ProxyID string `json:"proxy_id"`
164+}
165+
341166 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
342167 var req rendererBridgeExplicitTextCopyPayload
343168 if len(payload) == 0 {
@@ -355,6 +180,38 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
355180 return req, nil
356181 }
357182
183+func decodeRendererBridgePopupPayload[T any](payload []byte, proxyID func(T) string) (T, error) {
184+ var req T
185+ if len(payload) == 0 {
186+ return req, fmt.Errorf("empty payload")
187+ }
188+ if err := json.Unmarshal(payload, &req); err != nil {
189+ return req, err
190+ }
191+ if proxyID(req) == "" {
192+ return req, fmt.Errorf("missing proxy_id")
193+ }
194+ return req, nil
195+}
196+
197+func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
198+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupOpenPayload) string {
199+ return req.ProxyID
200+ })
201+}
202+
203+func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {
204+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupNavigatePayload) string {
205+ return req.ProxyID
206+ })
207+}
208+
209+func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {
210+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupClosePayload) string {
211+ return req.ProxyID
212+ })
213+}
214+
358215 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
359216 logger := logging.FromContext(e.currentContext())
360217 task := purecef.NewTask(cefTaskFunc(func() {
M internal/infrastructure/cef/renderer_bridge_test.go +82 -0
@@ -97,6 +97,17 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
9797 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
9898 }
9999
100+func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101+ require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102+ require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
106+ require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
107+ require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
108+ require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
109+}
110+
100111 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101112 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102113 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
@@ -130,6 +141,77 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
130141 require.Equal(t, "cut", req.Action)
131142 }
132143
144+func TestDecodeRendererBridgePopupOpenPayload(t *testing.T) {
145+ req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true,"no_javascript_access":true}`))
146+ require.NoError(t, err)
147+ require.Equal(t, "popup-1", req.ProxyID)
148+ require.Equal(t, "https://example.com/login", req.URL)
149+ require.Equal(t, "_blank", req.FrameName)
150+ require.True(t, req.UserGesture)
151+ require.True(t, req.NoJavaScriptAccess)
152+}
153+
154+func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) {
155+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
156+ require.NoError(t, err)
157+ require.Equal(t, "popup-1", req.ProxyID)
158+ require.Equal(t, "https://example.com/callback", req.URL)
159+}
160+
161+func TestDecodeRendererBridgePopupClosePayload(t *testing.T) {
162+ req, err := decodeRendererBridgePopupClosePayload([]byte(`{"proxy_id":"popup-1"}`))
163+ require.NoError(t, err)
164+ require.Equal(t, "popup-1", req.ProxyID)
165+}
166+
167+func TestDecodeRendererBridgePopupPayloadErrors(t *testing.T) {
168+ tests := []struct {
169+ name string
170+ decode func([]byte) error
171+ }{
172+ {
173+ name: "open",
174+ decode: func(payload []byte) error {
175+ _, err := decodeRendererBridgePopupOpenPayload(payload)
176+ return err
177+ },
178+ },
179+ {
180+ name: "navigate",
181+ decode: func(payload []byte) error {
182+ _, err := decodeRendererBridgePopupNavigatePayload(payload)
183+ return err
184+ },
185+ },
186+ {
187+ name: "close",
188+ decode: func(payload []byte) error {
189+ _, err := decodeRendererBridgePopupClosePayload(payload)
190+ return err
191+ },
192+ },
193+ }
194+
195+ payloads := []struct {
196+ name string
197+ payload []byte
198+ }{
199+ {name: "empty", payload: nil},
200+ {name: "invalid json", payload: []byte(`{invalid`)},
201+ {name: "missing proxy id", payload: []byte(`{"url":"https://example.com"}`)},
202+ }
203+
204+ for _, test := range tests {
205+ t.Run(test.name, func(t *testing.T) {
206+ for _, payload := range payloads {
207+ t.Run(payload.name, func(t *testing.T) {
208+ require.Error(t, test.decode(payload.payload))
209+ })
210+ }
211+ })
212+ }
213+}
214+
133215 func parseCEFPackageFiles(t *testing.T) []*ast.File {
134216 t.Helper()
135217 entries, err := os.ReadDir(".")
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 = errors.New("cef: related popup webviews are not supported yet")
13+ ErrRelatedWebViewUnsupported = domainerrors.ErrRelatedWebViewUnsupported
1314 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
1415 )
1516
M internal/infrastructure/cef/scheme_handler.go +160 -28
@@ -66,6 +66,15 @@ type dumbSchemeHandler struct {
6666 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
6767 onEditableFocus func(browser purecef.Browser)
6868
69+ // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
70+ // proxies from page JavaScript back into the browser process when native
71+ // related popups are not available (e.g. CEF OSR regular-webview fallback path).
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)
77+
6978 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
7079 // browser/navigation context that issued the request.
7180 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
@@ -130,9 +139,10 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
130139 }
131140
132141 // Route API requests before redirecting conceptual dumb:// URLs to the
133- // internal HTTPS origin. External pages use dumb://api/... for clipboard and
134- // focus bridges specifically to bypass page CSP; redirecting those requests
135- // to https://dumber.invalid makes site connect-src policies block them.
142+ // internal HTTPS origin. External pages use dumb://api/... for clipboard,
143+ // focus, and popup bridges specifically to bypass page CSP; redirecting
144+ // those requests to https://dumber.invalid makes site connect-src policies
145+ // block them.
136146 if apiPath, ok := resolveAPIPath(u); ok {
137147 return h.handleAPI(browser, method, apiPath, request)
138148 }
@@ -156,37 +166,73 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
156166 if strings.EqualFold(method, http.MethodOptions) {
157167 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
158168 }
159-
160- switch {
161- case path == "/api/message" && strings.EqualFold(method, "POST"):
162- body := readBodyFromHeader(request)
163- if body == nil {
164- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
169+ if isAPIGetMethod(method) {
170+ if handler, ok := h.handleAPIGet(path, request); ok {
171+ return handler
165172 }
166- resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
167- if err != nil {
168- return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
173+ }
174+ if isAPIPostMethod(method) {
175+ if handler, ok := h.handleAPIPost(browser, path, request); ok {
176+ return handler
169177 }
170- return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
171-
172- case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
173- return h.handleConfigAPI(h.currentConfigPayload)
174-
175- case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")):
176- return h.handleConfigAPI(h.defaultConfigPayload)
177-
178- case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
179- return h.handleTranscodeAPI(request)
178+ }
179+ return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
180+}
180181
181- case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
182- return h.handleClipboardSet(request, browser)
182+func isAPIGetMethod(method string) bool {
183+ return method == "" || strings.EqualFold(method, http.MethodGet)
184+}
183185
184- case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
185- return h.handleFocusSync(request, browser)
186+func isAPIPostMethod(method string) bool {
187+ return strings.EqualFold(method, http.MethodPost)
188+}
186189
190+func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
191+ switch path {
192+ case "/api/config":
193+ return h.handleConfigAPI(h.currentConfigPayload), true
194+ case "/api/config/default":
195+ return h.handleConfigAPI(h.defaultConfigPayload), true
196+ case "/api/transcode":
197+ return h.handleTranscodeAPI(request), true
187198 default:
188- return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
199+ return nil, false
200+ }
201+}
202+
203+func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) {
204+ switch path {
205+ case "/api/message":
206+ return h.handleMessageAPI(request), true
207+ case "/api/clipboard-set":
208+ return h.handleClipboardSet(request, browser), true
209+ case "/api/focus-sync":
210+ return h.handleFocusSync(request, browser), true
211+ case "/api/popup-open":
212+ return h.handlePopupOpen(request, browser), true
213+ case "/api/popup-navigate":
214+ return h.handlePopupNavigate(request, browser), true
215+ case "/api/popup-close":
216+ 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
221+ default:
222+ return nil, false
223+ }
224+}
225+
226+func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
227+ body := readBodyFromHeader(request)
228+ if body == nil {
229+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
230+ }
231+ resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
232+ if err != nil {
233+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
189234 }
235+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
190236 }
191237
192238 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
@@ -206,7 +252,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
206252 }
207253
208254 sourceParsed, err := url.Parse(sourceURL)
209- if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
255+ if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
210256 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
211257 }
212258
@@ -331,6 +377,92 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
331377 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
332378 }
333379
380+func handlePopupBridgeRequest[T any](
381+ h *dumbSchemeHandler,
382+ request purecef.Request,
383+ browser purecef.Browser,
384+ action string,
385+ decode func([]byte) (T, error),
386+ dispatch func(purecef.Browser, T),
387+) purecef.ResourceHandler {
388+ if browser == nil {
389+ h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
390+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
391+ }
392+ if !h.hasTrustedBridgeNonce(request, browser) {
393+ h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
394+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
395+ }
396+ body := readBodyFromHeader(request)
397+ if body == nil {
398+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
399+ }
400+ payload, err := decode(body)
401+ if err != nil {
402+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
403+ }
404+ if dispatch != nil {
405+ dispatch(browser, payload)
406+ } else {
407+ h.logger.Warn().Msg("cef: " + action + " — callback not wired")
408+ }
409+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
410+}
411+
412+func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
413+ return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
414+}
415+
416+func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
417+ return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
418+}
419+
420+func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
421+ return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
422+}
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+ if payload.TargetOrigin == "" {
446+ return payload, fmt.Errorf("missing target origin")
447+ }
448+ return payload, nil
449+}
450+
451+func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
452+ return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
453+}
454+
455+func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
456+ return handlePopupBridgeRequest(
457+ h,
458+ request,
459+ browser,
460+ "popup-opener-post-message",
461+ decodePopupOpenerPostMessagePayload,
462+ h.onPopupOpenerPostMessage,
463+ )
464+}
465+
334466 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335467 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336468 return false
M internal/infrastructure/cef/scheme_handler_test.go +236 -0
@@ -70,6 +70,20 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
7070 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
7171 }
7272
73+func TestDecodePopupOpenerPostMessagePayload_RejectsMissingTargetOrigin(t *testing.T) {
74+ _, err := decodePopupOpenerPostMessagePayload([]byte(`{"data":"{}","data_kind":"json","target_origin":" "}`))
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "target origin")
77+}
78+
79+func TestDecodePopupOpenerPostMessagePayload_TrimsAndAcceptsTargetOrigin(t *testing.T) {
80+ payload, err := decodePopupOpenerPostMessagePayload([]byte(`{"data":"{}","data_kind":"json","target_origin":" https://example.com ","source_origin":" https://popup.example.com ","source_href":" https://popup.example.com/callback "}`))
81+ require.NoError(t, err)
82+ require.Equal(t, "https://example.com", payload.TargetOrigin)
83+ require.Equal(t, "https://popup.example.com", payload.SourceOrigin)
84+ require.Equal(t, "https://popup.example.com/callback", payload.SourceHref)
85+}
86+
7387 func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
7488 oldNewResourceHandler := cefNewResourceHandler
7589 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -235,6 +249,228 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
235249 require.Positive(t, responseLength)
236250 }
237251
252+func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) {
253+ oldNewResourceHandler := cefNewResourceHandler
254+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
255+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
256+
257+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
258+ require.NoError(t, err)
259+
260+ const bridgeNonce = "bridge-nonce"
261+ browser := cefmocks.NewMockBrowser(t)
262+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
263+ return got == browser && gotNonce == bridgeNonce
264+ }
265+
266+ var got rendererBridgePopupOpenPayload
267+ h.onPopupOpen = func(gotBrowser purecef.Browser, payload rendererBridgePopupOpenPayload) {
268+ require.Same(t, browser, gotBrowser)
269+ got = payload
270+ }
271+
272+ request := cefmocks.NewMockRequest(t)
273+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
274+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true,"no_javascript_access":true}`))
275+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
276+
277+ response := cefmocks.NewMockResponse(t)
278+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
279+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
280+ response.EXPECT().SetMimeType("application/json").Once()
281+ expectAPIResponseHeaders(response)
282+
283+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-open", request)
284+ require.NotNil(t, handler)
285+
286+ var responseLength int64
287+ handler.GetResponseHeaders(response, &responseLength, 0)
288+ require.Positive(t, responseLength)
289+ require.Equal(t, "popup-1", got.ProxyID)
290+ require.Equal(t, "https://example.com/login", got.URL)
291+ require.Equal(t, "Google login", got.FrameName)
292+ require.True(t, got.UserGesture)
293+ require.True(t, got.NoJavaScriptAccess)
294+}
295+
296+func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) {
297+ oldNewResourceHandler := cefNewResourceHandler
298+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
299+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
300+
301+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
302+ require.NoError(t, err)
303+
304+ const bridgeNonce = "bridge-nonce"
305+ browser := cefmocks.NewMockBrowser(t)
306+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
307+ return got == browser && gotNonce == bridgeNonce
308+ }
309+
310+ var got rendererBridgePopupNavigatePayload
311+ h.onPopupNavigate = func(gotBrowser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
312+ require.Same(t, browser, gotBrowser)
313+ got = payload
314+ }
315+
316+ request := cefmocks.NewMockRequest(t)
317+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
318+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`))
319+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
320+
321+ response := cefmocks.NewMockResponse(t)
322+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
323+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
324+ response.EXPECT().SetMimeType("application/json").Once()
325+ expectAPIResponseHeaders(response)
326+
327+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-navigate", request)
328+ require.NotNil(t, handler)
329+
330+ var responseLength int64
331+ handler.GetResponseHeaders(response, &responseLength, 0)
332+ require.Positive(t, responseLength)
333+ require.Equal(t, "popup-1", got.ProxyID)
334+ require.Equal(t, "https://example.com/callback", got.URL)
335+}
336+
337+func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) {
338+ oldNewResourceHandler := cefNewResourceHandler
339+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
340+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
341+
342+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
343+ require.NoError(t, err)
344+
345+ const bridgeNonce = "bridge-nonce"
346+ browser := cefmocks.NewMockBrowser(t)
347+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
348+ return got == browser && gotNonce == bridgeNonce
349+ }
350+
351+ var got rendererBridgePopupClosePayload
352+ h.onPopupClose = func(gotBrowser purecef.Browser, payload rendererBridgePopupClosePayload) {
353+ require.Same(t, browser, gotBrowser)
354+ got = payload
355+ }
356+
357+ request := cefmocks.NewMockRequest(t)
358+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
359+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1"}`))
360+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
361+
362+ response := cefmocks.NewMockResponse(t)
363+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
364+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
365+ response.EXPECT().SetMimeType("application/json").Once()
366+ expectAPIResponseHeaders(response)
367+
368+ handler := h.handleAPI(browser, http.MethodPost, "/api/popup-close", request)
369+ require.NotNil(t, handler)
370+
371+ var responseLength int64
372+ handler.GetResponseHeaders(response, &responseLength, 0)
373+ require.Positive(t, responseLength)
374+ require.Equal(t, "popup-1", got.ProxyID)
375+}
376+
377+func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) {
378+ oldNewResourceHandler := cefNewResourceHandler
379+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
380+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
381+
382+ tests := []struct {
383+ name string
384+ path string
385+ body string
386+ wire func(*dumbSchemeHandler, *bool)
387+ }{
388+ {
389+ name: "open malformed json",
390+ path: "/api/popup-open",
391+ body: `{invalid`,
392+ wire: func(h *dumbSchemeHandler, called *bool) {
393+ h.onPopupOpen = func(purecef.Browser, rendererBridgePopupOpenPayload) { *called = true }
394+ },
395+ },
396+ {
397+ name: "open missing proxy id",
398+ path: "/api/popup-open",
399+ body: `{"url":"https://example.com/login"}`,
400+ wire: func(h *dumbSchemeHandler, called *bool) {
401+ h.onPopupOpen = func(purecef.Browser, rendererBridgePopupOpenPayload) { *called = true }
402+ },
403+ },
404+ {
405+ name: "navigate malformed json",
406+ path: "/api/popup-navigate",
407+ body: `{invalid`,
408+ wire: func(h *dumbSchemeHandler, called *bool) {
409+ h.onPopupNavigate = func(purecef.Browser, rendererBridgePopupNavigatePayload) { *called = true }
410+ },
411+ },
412+ {
413+ name: "navigate missing proxy id",
414+ path: "/api/popup-navigate",
415+ body: `{"url":"https://example.com/callback"}`,
416+ wire: func(h *dumbSchemeHandler, called *bool) {
417+ h.onPopupNavigate = func(purecef.Browser, rendererBridgePopupNavigatePayload) { *called = true }
418+ },
419+ },
420+ {
421+ name: "close malformed json",
422+ path: "/api/popup-close",
423+ body: `{invalid`,
424+ wire: func(h *dumbSchemeHandler, called *bool) {
425+ h.onPopupClose = func(purecef.Browser, rendererBridgePopupClosePayload) { *called = true }
426+ },
427+ },
428+ {
429+ name: "close missing proxy id",
430+ path: "/api/popup-close",
431+ body: `{"url":"https://example.com/callback"}`,
432+ wire: func(h *dumbSchemeHandler, called *bool) {
433+ h.onPopupClose = func(purecef.Browser, rendererBridgePopupClosePayload) { *called = true }
434+ },
435+ },
436+ }
437+
438+ for _, test := range tests {
439+ t.Run(test.name, func(t *testing.T) {
440+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
441+ require.NoError(t, err)
442+
443+ const bridgeNonce = "bridge-nonce"
444+ browser := cefmocks.NewMockBrowser(t)
445+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
446+ return got == browser && gotNonce == bridgeNonce
447+ }
448+
449+ called := false
450+ test.wire(h, &called)
451+
452+ request := cefmocks.NewMockRequest(t)
453+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
454+ encoded := base64.StdEncoding.EncodeToString([]byte(test.body))
455+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
456+
457+ response := cefmocks.NewMockResponse(t)
458+ response.EXPECT().SetStatus(int32(http.StatusBadRequest)).Once()
459+ response.EXPECT().SetStatusText(http.StatusText(http.StatusBadRequest)).Once()
460+ response.EXPECT().SetMimeType("application/json").Once()
461+ expectAPIResponseHeaders(response)
462+
463+ handler := h.handleAPI(browser, http.MethodPost, test.path, request)
464+ require.NotNil(t, handler)
465+
466+ var responseLength int64
467+ handler.GetResponseHeaders(response, &responseLength, 0)
468+ require.Positive(t, responseLength)
469+ require.False(t, called)
470+ })
471+ }
472+}
473+
238474 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
239475 oldNewResourceHandler := cefNewResourceHandler
240476 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/cef/webview.go +545 -6
@@ -23,9 +23,12 @@ import (
2323
2424 // Compile-time interface checks.
2525 var (
26- _ port.WebView = (*WebView)(nil)
27- _ port.NativeWidgetProvider = (*WebView)(nil)
28- _ port.DevToolsOpener = (*WebView)(nil)
26+ _ port.WebView = (*WebView)(nil)
27+ _ port.NativeWidgetProvider = (*WebView)(nil)
28+ _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.PopupLifecycleCapable = (*WebView)(nil)
30+ _ port.PopupOpenerCapable = (*WebView)(nil)
31+ _ port.OAuthCallbackCapable = (*WebView)(nil)
2932 )
3033
3134 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -38,6 +41,7 @@ const (
3841 clipboardSelectionDebounceDelay = 300 * time.Millisecond
3942 pendingNavigationRetryDelay = 50 * time.Millisecond
4043 pendingNavigationMaxRetries = 80
44+ nativePopupAttachFallbackDelay = 250 * time.Millisecond
4145 )
4246
4347 type stoppableTimer interface {
@@ -94,6 +98,35 @@ type WebView struct {
9498 mu sync.RWMutex
9599 callbacks *port.WebViewCallbacks
96100
101+ // Synthetic popup proxies created by the renderer bridge's window.open shim.
102+ syntheticPopupMu sync.Mutex
103+ syntheticPopups map[string]*syntheticPopupState
104+
105+ // Programmatic popup lifecycle callbacks used for OAuth auto-close and
106+ // synthetic window.open() proxy support on CEF.
107+ closeCallbacks []func()
108+ navigationCallbacks []func(string)
109+ openerMessageCallbacks []func()
110+ openerNavigationCallbacks []func(string)
111+ popupReadyToShow func()
112+ popupReadyShown bool
113+
114+ // Native popup bookkeeping. CreateRelated() returns a popup shell with no
115+ // browser attached yet; OnBeforePopup may wire CEF's real popup browser into
116+ // that shell. If native attachment is unavailable or stalls, the shell can
117+ // still create its own browser directly while preserving the same popup pane
118+ // lifecycle from the coordinator's perspective.
119+ nativePopupCandidate bool
120+ nativePopupParent *WebView
121+ nativePopupID int32
122+ nativePopupFallbackStarted bool
123+ nativePopupFallbackTimer stoppableTimer
124+ nativePopupFallbackSchedule func(time.Duration, func()) stoppableTimer
125+ pendingNativePopups map[int32]*WebView
126+ popupNoJavaScriptAccess bool
127+ popupOpenerBridgeParent *WebView
128+ popupOpenerBridgeParentURI string
129+
97130 // State cache (mutex-protected).
98131 uri string
99132 title string
@@ -126,6 +159,11 @@ type WebView struct {
126159 audioPlaying atomic.Bool
127160 zoomFactor atomic.Value // float64, initialized to 1.0
128161
162+ // Browser creation defaults copied from the factory so native popup shells
163+ // can apply the same settings in OnBeforePopup.
164+ windowlessFrameRate int32
165+ backgroundColor uint32
166+
129167 // Audio output factory and active stream.
130168 audioOutputFactory port.AudioOutputFactory
131169 audioStreamMu sync.Mutex
@@ -139,9 +177,11 @@ type WebView struct {
139177 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
140178 // deferred until the GL area has a non-zero size.
141179 type pendingBrowserCreate struct {
142- windowInfo *purecef.WindowInfo
143- client purecef.RawClient
144- settings *purecef.BrowserSettings
180+ windowInfo *purecef.WindowInfo
181+ client purecef.RawClient
182+ settings *purecef.BrowserSettings
183+ extraInfo purecef.DictionaryValue
184+ postTaskRetries int
145185 }
146186
147187 type cefTaskFunc func()
@@ -465,6 +505,408 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
465505 wv.callbacks = cb
466506 }
467507
508+// SetOnReadyToShow implements port.PopupLifecycleCapable.
509+func (wv *WebView) SetOnReadyToShow(fn func()) {
510+ if wv == nil {
511+ return
512+ }
513+ wv.mu.Lock()
514+ wv.popupReadyToShow = fn
515+ alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil
516+ wv.mu.Unlock()
517+ if alreadyReady {
518+ wv.fireReadyToShow()
519+ }
520+}
521+
522+// SetOnClose implements port.PopupLifecycleCapable.
523+func (wv *WebView) SetOnClose(fn func()) {
524+ wv.AddCloseCallback(fn)
525+}
526+
527+// Show implements port.PopupLifecycleCapable.
528+func (wv *WebView) Show() {
529+ if wv == nil || wv.destroyed.Load() {
530+ return
531+ }
532+ wv.mu.RLock()
533+ host := wv.host
534+ wv.mu.RUnlock()
535+ if host != nil {
536+ host.WasHidden(0)
537+ }
538+}
539+
540+// PrimePopupNavigation implements port.PopupLifecycleCapable.
541+func (wv *WebView) PrimePopupNavigation(uri string) {
542+ if wv == nil || wv.destroyed.Load() {
543+ return
544+ }
545+ actualURI := toActualInternalURL(strings.TrimSpace(uri))
546+ if actualURI == "" {
547+ return
548+ }
549+ wv.mu.Lock()
550+ browser := wv.browser
551+ wv.pendingURI = actualURI
552+ wv.mu.Unlock()
553+ if browser != nil {
554+ wv.schedulePendingNavigationReplay(0)
555+ }
556+}
557+
558+// AddCloseCallback implements port.OAuthCallbackCapable.
559+func (wv *WebView) AddCloseCallback(fn func()) {
560+ if wv == nil || fn == nil {
561+ return
562+ }
563+ wv.mu.Lock()
564+ wv.closeCallbacks = append(wv.closeCallbacks, fn)
565+ wv.mu.Unlock()
566+}
567+
568+// AddNavigationCallback implements port.OAuthCallbackCapable.
569+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
570+ if wv == nil || fn == nil {
571+ return
572+ }
573+ wv.mu.Lock()
574+ wv.navigationCallbacks = append(wv.navigationCallbacks, fn)
575+ wv.mu.Unlock()
576+}
577+
578+// Close implements port.OAuthCallbackCapable.
579+func (wv *WebView) Close() {
580+ if wv == nil || wv.destroyed.Load() {
581+ return
582+ }
583+ wv.mu.RLock()
584+ host := wv.host
585+ wv.mu.RUnlock()
586+ if host != nil {
587+ host.CloseBrowser(1)
588+ return
589+ }
590+ wv.runCloseCallbacks()
591+}
592+
593+func (wv *WebView) fireReadyToShow() {
594+ if wv == nil {
595+ return
596+ }
597+ wv.mu.Lock()
598+ if wv.popupReadyShown || wv.popupReadyToShow == nil {
599+ wv.mu.Unlock()
600+ return
601+ }
602+ fn := wv.popupReadyToShow
603+ wv.popupReadyShown = true
604+ wv.mu.Unlock()
605+ wv.runOnGTK(fn)
606+}
607+
608+func (wv *WebView) markNativePopupCandidate(parent *WebView) {
609+ if wv == nil {
610+ return
611+ }
612+ wv.stopNativePopupFallbackTimer()
613+ wv.mu.Lock()
614+ wv.nativePopupCandidate = true
615+ wv.nativePopupParent = parent
616+ wv.nativePopupID = 0
617+ wv.nativePopupFallbackStarted = false
618+ wv.mu.Unlock()
619+}
620+
621+func (wv *WebView) isNativePopupCandidate() bool {
622+ if wv == nil {
623+ return false
624+ }
625+ wv.mu.RLock()
626+ defer wv.mu.RUnlock()
627+ return wv.nativePopupCandidate
628+}
629+
630+func popupParentWindowHandle(parent *WebView) uintptr {
631+ if parent == nil {
632+ return 0
633+ }
634+ parent.mu.RLock()
635+ host := parent.host
636+ parent.mu.RUnlock()
637+ if host == nil {
638+ return 0
639+ }
640+ if handle := host.GetWindowHandle(); handle != 0 {
641+ return handle
642+ }
643+ return host.GetOpenerWindowHandle()
644+}
645+
646+func (wv *WebView) discardNativePopupCandidate() {
647+ if wv == nil {
648+ return
649+ }
650+ wv.mu.Lock()
651+ wv.nativePopupCandidate = false
652+ wv.nativePopupParent = nil
653+ wv.nativePopupID = 0
654+ wv.mu.Unlock()
655+}
656+
657+func (wv *WebView) awaitsNativePopupAttachment() bool {
658+ if wv == nil {
659+ return false
660+ }
661+ wv.mu.RLock()
662+ defer wv.mu.RUnlock()
663+ return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
664+}
665+
666+func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
667+ if wv != nil && wv.nativePopupFallbackSchedule != nil {
668+ return wv.nativePopupFallbackSchedule
669+ }
670+ return func(delay time.Duration, fn func()) stoppableTimer {
671+ return time.AfterFunc(delay, fn)
672+ }
673+}
674+
675+func (wv *WebView) stopNativePopupFallbackTimer() {
676+ if wv == nil {
677+ return
678+ }
679+ wv.mu.Lock()
680+ timer := wv.nativePopupFallbackTimer
681+ wv.nativePopupFallbackTimer = nil
682+ wv.mu.Unlock()
683+ if timer != nil {
684+ timer.Stop()
685+ }
686+}
687+
688+func (wv *WebView) scheduleNativePopupFallback(delay time.Duration, fn func()) {
689+ if wv == nil || fn == nil || wv.destroyed.Load() {
690+ return
691+ }
692+ wv.stopNativePopupFallbackTimer()
693+ var timer stoppableTimer
694+ timer = wv.nativePopupFallbackScheduler()(delay, func() {
695+ wv.mu.Lock()
696+ if wv.destroyed.Load() || wv.nativePopupFallbackTimer != timer {
697+ wv.mu.Unlock()
698+ return
699+ }
700+ wv.nativePopupFallbackTimer = nil
701+ wv.mu.Unlock()
702+ fn()
703+ })
704+ wv.mu.Lock()
705+ if wv.destroyed.Load() {
706+ wv.mu.Unlock()
707+ if timer != nil {
708+ timer.Stop()
709+ }
710+ return
711+ }
712+ wv.nativePopupFallbackTimer = timer
713+ wv.mu.Unlock()
714+}
715+
716+func (wv *WebView) preparePopupShellDirectBrowserCreation() bool {
717+ if wv == nil {
718+ return false
719+ }
720+ wv.stopNativePopupFallbackTimer()
721+ wv.mu.Lock()
722+ if wv.destroyed.Load() || wv.browser != nil || wv.nativePopupFallbackStarted || wv.pendingCreate == nil {
723+ wv.mu.Unlock()
724+ return false
725+ }
726+ parent := wv.nativePopupParent
727+ popupID := wv.nativePopupID
728+ wv.nativePopupFallbackStarted = true
729+ wv.nativePopupCandidate = false
730+ wv.nativePopupParent = nil
731+ wv.nativePopupID = 0
732+ if !wv.popupNoJavaScriptAccess && parent != nil && !parent.destroyed.Load() {
733+ wv.popupOpenerBridgeParent = parent
734+ wv.popupOpenerBridgeParentURI = parent.URI()
735+ } else {
736+ wv.popupOpenerBridgeParent = nil
737+ wv.popupOpenerBridgeParentURI = ""
738+ }
739+ wv.syncPopupOpenerBridgeExtraInfoLocked()
740+ wv.mu.Unlock()
741+ if parent != nil && popupID != 0 {
742+ parent.clearPendingNativePopup(popupID, wv)
743+ }
744+ return true
745+}
746+
747+func (wv *WebView) startNativePopupFallback() bool {
748+ if wv == nil {
749+ return false
750+ }
751+ wv.mu.RLock()
752+ destroyed := wv.destroyed.Load()
753+ alreadyStarted := wv.nativePopupFallbackStarted
754+ wv.mu.RUnlock()
755+ if destroyed || alreadyStarted {
756+ return false
757+ }
758+ return wv.preparePopupShellDirectBrowserCreation()
759+}
760+
761+func (wv *WebView) trackPendingNativePopup(popupID int32, popup *WebView) {
762+ if wv == nil || popupID == 0 || popup == nil {
763+ return
764+ }
765+ wv.mu.Lock()
766+ if wv.pendingNativePopups == nil {
767+ wv.pendingNativePopups = make(map[int32]*WebView)
768+ }
769+ wv.pendingNativePopups[popupID] = popup
770+ wv.mu.Unlock()
771+}
772+
773+func (wv *WebView) takePendingNativePopup(popupID int32) *WebView {
774+ if wv == nil || popupID == 0 {
775+ return nil
776+ }
777+ wv.mu.Lock()
778+ defer wv.mu.Unlock()
779+ popup := wv.pendingNativePopups[popupID]
780+ delete(wv.pendingNativePopups, popupID)
781+ return popup
782+}
783+
784+func (wv *WebView) clearPendingNativePopup(popupID int32, popup *WebView) {
785+ if wv == nil || popupID == 0 || popup == nil {
786+ return
787+ }
788+ wv.mu.Lock()
789+ defer wv.mu.Unlock()
790+ if current := wv.pendingNativePopups[popupID]; current == popup {
791+ delete(wv.pendingNativePopups, popupID)
792+ }
793+}
794+
795+func (wv *WebView) prepareNativePopup(
796+ popupID int32,
797+ targetURL string,
798+ windowInfo *purecef.WindowInfo,
799+ clientSlot *purecef.RawClientWriteSlot,
800+ settings *purecef.BrowserSettings,
801+) bool {
802+ if wv == nil || wv.destroyed.Load() || popupID == 0 || windowInfo == nil || clientSlot == nil {
803+ return false
804+ }
805+
806+ prep, ok := wv.nativePopupPreparationSnapshot()
807+ if !ok {
808+ return false
809+ }
810+ parentWindowHandle := popupParentWindowHandle(prep.parent)
811+ if parentWindowHandle == 0 {
812+ return false
813+ }
814+ client, ok := wv.activateNativePopup(popupID, targetURL)
815+ if !ok {
816+ return false
817+ }
818+ if prep.parent != nil {
819+ prep.parent.trackPendingNativePopup(popupID, wv)
820+ }
821+
822+ configureNativePopupWindow(windowInfo, settings, parentWindowHandle, prep.frameRate, prep.backgroundColor)
823+ clientSlot.Set(client)
824+ return true
825+}
826+
827+func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, bool) {
828+ if wv == nil {
829+ return nativePopupPreparation{}, false
830+ }
831+ wv.mu.RLock()
832+ defer wv.mu.RUnlock()
833+ if !wv.canPrepareNativePopupLocked() {
834+ return nativePopupPreparation{}, false
835+ }
836+ return nativePopupPreparation{
837+ parent: wv.nativePopupParent,
838+ frameRate: wv.windowlessFrameRate,
839+ backgroundColor: wv.backgroundColor,
840+ }, true
841+}
842+
843+func (wv *WebView) activateNativePopup(popupID int32, targetURL string) (purecef.RawClient, bool) {
844+ if wv == nil {
845+ return nil, false
846+ }
847+ wv.mu.Lock()
848+ defer wv.mu.Unlock()
849+ if !wv.canPrepareNativePopupLocked() {
850+ return nil, false
851+ }
852+ wv.nativePopupCandidate = false
853+ wv.nativePopupID = popupID
854+ wv.nativePopupFallbackStarted = false
855+ if strings.TrimSpace(wv.pendingURI) == "" {
856+ wv.pendingURI = toActualInternalURL(targetURL)
857+ }
858+ if strings.TrimSpace(wv.pendingURI) != "" {
859+ wv.uri = toConceptualInternalURL(wv.pendingURI)
860+ } else {
861+ wv.uri = toConceptualInternalURL(targetURL)
862+ }
863+ wv.isLoading = true
864+ return wv.client, true
865+}
866+
867+func (wv *WebView) canPrepareNativePopupLocked() bool {
868+ return wv.nativePopupCandidate && wv.client != nil && !wv.nativePopupFallbackStarted && wv.browser == nil
869+}
870+
871+func configureNativePopupWindow(
872+ windowInfo *purecef.WindowInfo,
873+ settings *purecef.BrowserSettings,
874+ parentWindowHandle uintptr,
875+ frameRate int32,
876+ backgroundColor uint32,
877+) {
878+ purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false)
879+ if externalBeginFrameEnabled() {
880+ windowInfo.ExternalBeginFrameEnabled = 1
881+ }
882+ if settings == nil {
883+ return
884+ }
885+ if frameRate > 0 {
886+ settings.WindowlessFrameRate = frameRate
887+ }
888+ settings.LocalStorage = 1
889+ if backgroundColor != 0 {
890+ settings.BackgroundColor = backgroundColor
891+ }
892+}
893+
894+type nativePopupPreparation struct {
895+ parent *WebView
896+ frameRate int32
897+ backgroundColor uint32
898+}
899+
900+func (wv *WebView) handleNativePopupAborted() {
901+ if wv == nil {
902+ return
903+ }
904+ wv.mu.Lock()
905+ wv.nativePopupCandidate = false
906+ wv.nativePopupID = 0
907+ wv.mu.Unlock()
908+}
909+
468910 // ---------------------------------------------------------------------------
469911 // JavaScript / Appearance
470912 // ---------------------------------------------------------------------------
@@ -509,6 +951,19 @@ func (wv *WebView) rotateBridgeNonce() string {
509951 return nonce
510952 }
511953
954+func (wv *WebView) ensureBridgeNonce() string {
955+ if wv == nil {
956+ return ""
957+ }
958+ wv.mu.RLock()
959+ nonce := wv.bridgeNonce
960+ wv.mu.RUnlock()
961+ if nonce != "" {
962+ return nonce
963+ }
964+ return wv.rotateBridgeNonce()
965+}
966+
512967 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
513968 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
514969 script := fmt.Sprintf(
@@ -536,6 +991,23 @@ func (wv *WebView) Destroy() {
536991 if !wv.destroyed.CompareAndSwap(false, true) {
537992 return
538993 }
994+ wv.syntheticPopupMu.Lock()
995+ wv.syntheticPopups = nil
996+ wv.syntheticPopupMu.Unlock()
997+ wv.mu.Lock()
998+ wv.navigationCallbacks = nil
999+ wv.openerMessageCallbacks = nil
1000+ wv.openerNavigationCallbacks = nil
1001+ wv.popupReadyToShow = nil
1002+ wv.pendingNativePopups = nil
1003+ wv.nativePopupParent = nil
1004+ wv.nativePopupCandidate = false
1005+ wv.nativePopupID = 0
1006+ wv.nativePopupFallbackStarted = false
1007+ wv.popupOpenerBridgeParent = nil
1008+ wv.popupOpenerBridgeParentURI = ""
1009+ wv.mu.Unlock()
1010+ wv.stopNativePopupFallbackTimer()
5391011 wv.cancelSelectionDebounce()
5401012 if wv.resizeReconciler != nil {
5411013 wv.resizeReconciler.stop()
@@ -547,6 +1019,8 @@ func (wv *WebView) Destroy() {
5471019 wv.mu.RUnlock()
5481020 if host != nil {
5491021 host.CloseBrowser(1)
1022+ } else {
1023+ wv.runCloseCallbacks()
5501024 }
5511025 if wv.pipeline != nil {
5521026 wv.pipeline.destroy()
@@ -593,6 +1067,7 @@ func (wv *WebView) updateURI(uri string) {
5931067 cb.OnURIChanged(uri)
5941068 })
5951069 }
1070+ wv.runNavigationCallbacks(uri)
5961071 }
5971072
5981073 func (wv *WebView) updateTitle(title string) {
@@ -1119,6 +1594,70 @@ func (wv *WebView) runOnGTK(fn func()) {
11191594 glib.IdleAddOnce(cb, 0)
11201595 }
11211596
1597+func (wv *WebView) isOnGTKThread() bool {
1598+ mainContext := glib.MainContextDefault()
1599+ return mainContext != nil && mainContext.IsOwner()
1600+}
1601+
1602+// runOnGTKSync executes fn on the GTK main context and waits for completion.
1603+// Callers already running on the GTK thread must execute inline to avoid
1604+// self-deadlocking while waiting for an IdleAddOnce callback.
1605+func (wv *WebView) runOnGTKSync(fn func()) {
1606+ if fn == nil {
1607+ return
1608+ }
1609+ if wv == nil || wv.engine == nil || wv.isOnGTKThread() {
1610+ fn()
1611+ return
1612+ }
1613+
1614+ done := make(chan struct{})
1615+ wv.runOnGTK(func() {
1616+ defer close(done)
1617+ fn()
1618+ })
1619+ <-done
1620+}
1621+
1622+func (wv *WebView) runCloseCallbacks() {
1623+ if wv == nil {
1624+ return
1625+ }
1626+ wv.mu.Lock()
1627+ callbacks := append([]func(){}, wv.closeCallbacks...)
1628+ wv.closeCallbacks = nil
1629+ wv.mu.Unlock()
1630+ if len(callbacks) == 0 {
1631+ return
1632+ }
1633+ wv.runOnGTK(func() {
1634+ for _, fn := range callbacks {
1635+ if fn != nil {
1636+ fn()
1637+ }
1638+ }
1639+ })
1640+}
1641+
1642+func (wv *WebView) runNavigationCallbacks(uri string) {
1643+ if wv == nil {
1644+ return
1645+ }
1646+ wv.mu.RLock()
1647+ callbacks := append([]func(string){}, wv.navigationCallbacks...)
1648+ wv.mu.RUnlock()
1649+ if len(callbacks) == 0 {
1650+ return
1651+ }
1652+ wv.runOnGTK(func() {
1653+ for _, fn := range callbacks {
1654+ if fn != nil {
1655+ fn(uri)
1656+ }
1657+ }
1658+ })
1659+}
1660+
11221661 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
11231662 wv.mu.Lock()
11241663 defer wv.mu.Unlock()
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+}
M internal/infrastructure/cef/webview_handlers.go +160 -39
@@ -107,6 +107,27 @@ func (h *handlerSet) OnProcessMessageReceived(
107107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
108108 case rendererBridgeActionFocusSync:
109109 h.wv.engine.handleEditableFocusBridge(browser)
110+ case rendererBridgeActionPopupOpen:
111+ req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
112+ if err != nil {
113+ log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114+ return 1
115+ }
116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
117+ case rendererBridgeActionPopupNavigate:
118+ req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
119+ if err != nil {
120+ log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
121+ return 1
122+ }
123+ h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
124+ case rendererBridgeActionPopupClose:
125+ req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
126+ if err != nil {
127+ log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
128+ return 1
129+ }
130+ h.wv.handleSyntheticPopupClose(req.ProxyID)
110131 case rendererBridgeActionReady:
111132 log.Debug().
112133 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
@@ -580,6 +601,20 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
580601 if frame == nil || !frame.IsMain() {
581602 return
582603 }
604+ bridgeNonce := h.wv.ensureBridgeNonce()
605+ if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
606+ if h.wv != nil && h.wv.ctx != nil {
607+ parentURI, active, blocked := h.wv.popupOpenerBridgeState()
608+ logging.FromContext(h.wv.ctx).Debug().
609+ Uint64("webview_id", uint64(h.wv.id)).
610+ Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
611+ Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
612+ Bool("bridge_active", active).
613+ Bool("bridge_blocked", blocked).
614+ Msg("cef: injecting popup opener bridge")
615+ }
616+ frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
617+ }
583618 if h.wv != nil && h.wv.ctx != nil {
584619 logging.FromContext(h.wv.ctx).Debug().
585620 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
@@ -651,13 +686,15 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
651686 // ===========================================================================
652687
653688 // OnBeforePopup intercepts popup requests (target="_blank", window.open).
654-// CEF OSR cannot create popup windows, so we fire the OnCreate callback
655-// to let the coordinator open the link in a new stacked pane.
689+// When the coordinator returns a related CEF popup shell we hand that shell's
690+// client back to CEF and allow native popup creation so opener semantics are
691+// preserved. Otherwise we keep blocking and let the coordinator's fallback
692+// pane handle the navigation.
656693 func (h *handlerSet) OnBeforePopup(
657- _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
694+ _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
658695 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
659- _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,
660- _ *purecef.DictionaryValue, _ *bool,
696+ windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
697+ _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
661698 ) bool {
662699 if targetURL == "" {
663700 return true
@@ -666,23 +703,47 @@ func (h *handlerSet) OnBeforePopup(
666703 h.wv.mu.RLock()
667704 cb := h.wv.callbacks
668705 h.wv.mu.RUnlock()
706+ if cb == nil || cb.OnCreate == nil {
707+ return true
708+ }
669709
670- if cb != nil && cb.OnCreate != nil {
671- req := port.PopupRequest{
672- TargetURI: targetURL,
673- FrameName: targetFrameName,
674- IsUserGesture: userGesture != 0,
675- ParentViewID: h.wv.id,
676- }
677- h.wv.runOnGTK(func() {
678- cb.OnCreate(req)
710+ requestNoJavaScriptAccess := false
711+ if noJavaScriptAccess != nil {
712+ requestNoJavaScriptAccess = *noJavaScriptAccess
713+ }
714+ var popup port.WebView
715+ h.wv.runOnGTKSync(func() {
716+ popup = cb.OnCreate(port.PopupRequest{
717+ TargetURI: targetURL,
718+ FrameName: targetFrameName,
719+ IsUserGesture: userGesture != 0,
720+ NoJavaScriptAccess: requestNoJavaScriptAccess,
721+ ParentViewID: h.wv.id,
679722 })
723+ })
724+
725+ cefPopup, ok := popup.(*WebView)
726+ if !ok || cefPopup == nil {
727+ return true
728+ }
729+ cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
730+ if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
731+ return false
680732 }
681733
682- return true // always block CEF popup; the coordinator handles the new pane
734+ cefPopup.discardNativePopupCandidate()
735+ logging.FromContext(h.currentContext()).Debug().
736+ Int32("popup_id", popupID).
737+ Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
738+ Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
739+ return true
683740 }
684741
685-func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {}
742+func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
743+ if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
744+ popup.handleNativePopupAborted()
745+ }
746+}
686747
687748 func (h *handlerSet) OnBeforeDevToolsPopup(
688749 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
@@ -698,45 +759,88 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
698759 return
699760 }
700761 browserID := browser.GetIdentifier()
701- log.Debug().
702- Int32("browser_id", browserID).
703- Msg("cef: OnAfterCreated")
704- if h.wv.engine != nil {
705- h.wv.engine.recordBrowserAfterCreated(browser)
706- h.wv.engine.registerWebView(h.wv)
707- }
762+ log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
763+
708764 host := browser.GetHost()
709765 if host == nil {
710766 log.Warn().Msg("cef: OnAfterCreated returned nil host")
711767 return
712768 }
713769
714- shouldSyncFocus := false
715- hasPendingNavigation := false
770+ state := h.attachAfterCreatedBrowser(browser, host, browserID)
771+ if state.closeDuplicate {
772+ log.Warn().
773+ Int32("browser_id", browserID).
774+ Int32("existing_browser_id", state.duplicateBrowserID).
775+ Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
776+ host.CloseBrowser(1)
777+ return
778+ }
779+
780+ h.finishAfterCreated(browser, host, state)
781+}
782+
783+func (h *handlerSet) attachAfterCreatedBrowser(
784+ browser purecef.Browser,
785+ host purecef.BrowserHost,
786+ browserID int32,
787+) afterCreatedState {
788+ state := afterCreatedState{}
716789 h.wv.mu.Lock()
790+ defer h.wv.mu.Unlock()
791+
792+ if existing := h.wv.browser; existing != nil {
793+ existingID := existing.GetIdentifier()
794+ if existingID != 0 && existingID != browserID {
795+ state.closeDuplicate = true
796+ state.duplicateBrowserID = existingID
797+ return state
798+ }
799+ }
800+
717801 h.wv.browser = browser
718802 h.wv.host = host
803+ h.wv.pendingCreate = nil
719804 h.wv.input.setHost(host)
720805 if h.wv.findCtrl != nil {
721806 h.wv.findCtrl.setHost(host)
722807 }
808+ state.nativePopupParent = h.wv.nativePopupParent
809+ state.nativePopupID = h.wv.nativePopupID
810+ h.wv.nativePopupParent = nil
811+ h.wv.nativePopupID = 0
812+ h.wv.nativePopupFallbackStarted = false
723813
724814 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
725815 // painting/caret updates until explicitly told the browser is shown.
726816 host.WasHidden(0)
817+ state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
818+ state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
819+ return state
820+}
727821
728- // If GTK focus entered before the browser existed, reassert browser focus
729- // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
730- if h.wv.input != nil {
731- shouldSyncFocus = h.wv.input.hasGTKFocus()
732- if !shouldSyncFocus && h.wv.input.glArea != nil {
733- shouldSyncFocus = h.wv.input.glArea.HasFocus()
734- }
822+func (h *handlerSet) afterCreatedShouldSyncFocus() bool {
823+ if h.wv.input == nil {
824+ return false
735825 }
736- hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
826+ if h.wv.input.hasGTKFocus() {
827+ return true
828+ }
829+ return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus()
830+}
737831
738- h.wv.mu.Unlock()
739- if shouldSyncFocus {
832+func (h *handlerSet) finishAfterCreated(
833+ browser purecef.Browser,
834+ host purecef.BrowserHost,
835+ state afterCreatedState,
836+) {
837+ if h.wv.engine != nil {
838+ h.wv.engine.recordBrowserAfterCreated(browser)
839+ h.wv.engine.registerWebView(h.wv)
840+ h.wv.engine.bindBrowserWebView(browser, h.wv)
841+ }
842+ h.wv.stopNativePopupFallbackTimer()
843+ if state.shouldSyncFocus {
740844 syncWindowlessBrowserFocus(host)
741845 } else {
742846 // Request an initial OSR frame even before the first real navigation
@@ -744,11 +848,23 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
744848 // startup path relied on and prevents the render pipeline from staying idle.
745849 host.Invalidate(purecef.PaintElementTypePetView)
746850 }
747- if hasPendingNavigation {
851+ if state.hasPendingNavigation {
748852 h.wv.schedulePendingNavigationReplay(0)
749853 }
750-
854+ if state.nativePopupParent != nil && state.nativePopupID != 0 {
855+ state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
856+ }
751857 h.wv.scheduleStartBeginFrameLoop()
858+ h.wv.fireReadyToShow()
859+}
860+
861+type afterCreatedState struct {
862+ shouldSyncFocus bool
863+ hasPendingNavigation bool
864+ nativePopupParent *WebView
865+ nativePopupID int32
866+ duplicateBrowserID int32
867+ closeDuplicate bool
752868 }
753869
754870 // DoClose returns false to allow the default close behavior.
@@ -757,9 +873,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
757873 }
758874
759875 // OnBeforeClose fires the OnClose callback.
760-func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
876+func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
761877 if h.wv.engine != nil {
762- h.wv.engine.unregisterWebView(h.wv)
878+ browserID := int32(0)
879+ if browser != nil {
880+ browserID = browser.GetIdentifier()
881+ }
882+ h.wv.engine.unregisterWebView(h.wv, browserID)
763883 }
764884 h.wv.mu.Lock()
765885 h.wv.browser = nil
@@ -780,6 +900,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
780900 cb.OnClose()
781901 })
782902 }
903+ h.wv.runCloseCallbacks()
783904 }
784905
785906 // ===========================================================================
M internal/infrastructure/cef/webview_handlers_test.go +162 -0
@@ -101,6 +101,159 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
101101 return nil
102102 }
103103
104+type stubStoppableTimer struct{}
105+
106+func (stubStoppableTimer) Stop() bool { return true }
107+
108+func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
109+ wv := &WebView{
110+ nativePopupCandidate: true,
111+ pendingCreate: &pendingBrowserCreate{},
112+ }
113+
114+ require.True(t, wv.startNativePopupFallback())
115+ require.False(t, wv.isNativePopupCandidate())
116+ require.True(t, wv.nativePopupFallbackStarted)
117+}
118+
119+func TestPreparePopupShellDirectBrowserCreation_EnablesSyntheticOpenerBridge(t *testing.T) {
120+ parent := &WebView{ctx: context.Background()}
121+ parent.updateURI("https://example.com/login")
122+ wv := &WebView{
123+ pendingCreate: &pendingBrowserCreate{},
124+ nativePopupParent: parent,
125+ }
126+
127+ require.True(t, wv.preparePopupShellDirectBrowserCreation())
128+ require.Same(t, parent, wv.popupOpenerBridgeParent)
129+ require.Equal(t, "https://example.com/login", wv.popupOpenerBridgeParentURI)
130+}
131+
132+func TestPreparePopupShellDirectBrowserCreation_SkipsDestroyedParentOpenerBridge(t *testing.T) {
133+ parent := &WebView{ctx: context.Background()}
134+ parent.updateURI("https://example.com/login")
135+ parent.destroyed.Store(true)
136+ wv := &WebView{
137+ pendingCreate: &pendingBrowserCreate{},
138+ nativePopupParent: parent,
139+ }
140+
141+ require.True(t, wv.preparePopupShellDirectBrowserCreation())
142+ require.Nil(t, wv.popupOpenerBridgeParent)
143+ require.Empty(t, wv.popupOpenerBridgeParentURI)
144+}
145+
146+func TestScheduleNativePopupFallback_SkipsDestroyedWebView(t *testing.T) {
147+ called := false
148+ wv := &WebView{}
149+ wv.destroyed.Store(true)
150+ wv.nativePopupFallbackSchedule = func(time.Duration, func()) stoppableTimer {
151+ called = true
152+ return stubStoppableTimer{}
153+ }
154+
155+ wv.scheduleNativePopupFallback(time.Millisecond, func() {
156+ called = true
157+ })
158+
159+ require.False(t, called)
160+}
161+
162+func TestScheduleNativePopupFallback_DoesNotFireAfterDestroy(t *testing.T) {
163+ called := false
164+ var scheduled func()
165+ wv := &WebView{}
166+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
167+ scheduled = fn
168+ return stubStoppableTimer{}
169+ }
170+
171+ wv.scheduleNativePopupFallback(time.Millisecond, func() {
172+ called = true
173+ })
174+ require.NotNil(t, scheduled)
175+
176+ wv.destroyed.Store(true)
177+ scheduled()
178+
179+ require.False(t, called)
180+}
181+
182+func TestStartNativePopupFallback_RejectsDestroyedWebView(t *testing.T) {
183+ wv := &WebView{
184+ nativePopupCandidate: true,
185+ pendingCreate: &pendingBrowserCreate{},
186+ }
187+ wv.destroyed.Store(true)
188+
189+ require.False(t, wv.startNativePopupFallback())
190+}
191+
192+func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) {
193+ closed := false
194+ parent := &WebView{}
195+ wv := &WebView{
196+ nativePopupCandidate: true,
197+ nativePopupParent: parent,
198+ nativePopupID: 55,
199+ pendingCreate: &pendingBrowserCreate{},
200+ pendingURI: "https://example.com/oauth",
201+ isLoading: true,
202+ closeCallbacks: []func(){
203+ func() { closed = true },
204+ },
205+ }
206+
207+ wv.handleNativePopupAborted()
208+
209+ require.False(t, closed)
210+ require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
211+ require.False(t, wv.isNativePopupCandidate())
212+ require.Same(t, parent, wv.nativePopupParent)
213+ require.Equal(t, int32(0), wv.nativePopupID)
214+ require.True(t, wv.isLoading)
215+}
216+
217+func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) {
218+ parentWV := &WebView{ctx: context.Background(), id: 17}
219+ popupWV := &WebView{ctx: context.Background(), id: 23}
220+ parentWV.SetCallbacks(&port.WebViewCallbacks{
221+ OnCreate: func(req port.PopupRequest) port.WebView {
222+ require.Equal(t, "https://example.com/popup", req.TargetURI)
223+ require.Equal(t, "auth-popup", req.FrameName)
224+ require.True(t, req.IsUserGesture)
225+ popupWV.PrimePopupNavigation(req.TargetURI)
226+ return popupWV
227+ },
228+ })
229+
230+ h := &handlerSet{wv: parentWV}
231+ blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil)
232+
233+ require.True(t, blocked)
234+ require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI())
235+}
236+
237+func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) {
238+ parentWV := &WebView{ctx: context.Background(), id: 31}
239+ popupWV := &WebView{ctx: context.Background(), id: 32}
240+ popupWV.markNativePopupCandidate(parentWV)
241+ parentWV.SetCallbacks(&port.WebViewCallbacks{
242+ OnCreate: func(req port.PopupRequest) port.WebView {
243+ require.Equal(t, "https://example.com/login", req.TargetURI)
244+ popupWV.PrimePopupNavigation(req.TargetURI)
245+ return popupWV
246+ },
247+ })
248+
249+ h := &handlerSet{wv: parentWV}
250+ blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil)
251+
252+ require.True(t, blocked)
253+ require.False(t, popupWV.isNativePopupCandidate())
254+ require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
255+}
256+
104257 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
105258 wv := &WebView{ctx: context.Background()}
106259 var gotEvents []port.LoadEvent
@@ -282,6 +435,15 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
282435 require.Equal(t, "free selection", recorder.selection.Text)
283436 }
284437
438+func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
439+ called := false
440+ wv := &WebView{closeCallbacks: []func(){func() { called = true }}}
441+
442+ wv.Destroy()
443+
444+ require.True(t, called)
445+}
446+
285447 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
286448 scheduler := &controlledSelectionScheduler{}
287449 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
A internal/infrastructure/cef/webview_popup_proxy.go +194 -0
@@ -0,0 +1,194 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+type syntheticPopupState struct {
12+ WebView port.WebView
13+ PendingURI string
14+ Closed bool
15+}
16+
17+// syntheticPopupStateLocked returns the synthetic popup state for proxyID.
18+// Caller must hold wv.syntheticPopupMu.
19+func (wv *WebView) syntheticPopupStateLocked(proxyID string) *syntheticPopupState {
20+ if wv == nil || proxyID == "" {
21+ return nil
22+ }
23+ if wv.syntheticPopups == nil {
24+ wv.syntheticPopups = make(map[string]*syntheticPopupState)
25+ }
26+ state := wv.syntheticPopups[proxyID]
27+ if state == nil {
28+ state = &syntheticPopupState{}
29+ wv.syntheticPopups[proxyID] = state
30+ }
31+ return state
32+}
33+
34+func (wv *WebView) deleteSyntheticPopupState(proxyID string) {
35+ if wv == nil || proxyID == "" {
36+ return
37+ }
38+
39+ wv.syntheticPopupMu.Lock()
40+ defer wv.syntheticPopupMu.Unlock()
41+ delete(wv.syntheticPopups, proxyID)
42+}
43+
44+func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {
45+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
46+ return
47+ }
48+
49+ wv.syntheticPopupMu.Lock()
50+ state := wv.syntheticPopupStateLocked(proxyID)
51+ if state.Closed {
52+ delete(wv.syntheticPopups, proxyID)
53+ wv.syntheticPopupMu.Unlock()
54+ return
55+ }
56+ wv.syntheticPopupMu.Unlock()
57+
58+ wv.runOnGTK(func() {
59+ if wv.destroyed.Load() {
60+ return
61+ }
62+
63+ wv.syntheticPopupMu.Lock()
64+ if state := wv.syntheticPopups[proxyID]; state != nil && state.Closed {
65+ delete(wv.syntheticPopups, proxyID)
66+ wv.syntheticPopupMu.Unlock()
67+ return
68+ }
69+ wv.syntheticPopupMu.Unlock()
70+
71+ wv.mu.RLock()
72+ cb := wv.callbacks
73+ wv.mu.RUnlock()
74+ if cb == nil || cb.OnCreate == nil {
75+ wv.deleteSyntheticPopupState(proxyID)
76+ return
77+ }
78+
79+ popupWV := cb.OnCreate(port.PopupRequest{
80+ TargetURI: targetURL,
81+ FrameName: frameName,
82+ IsUserGesture: userGesture,
83+ NoJavaScriptAccess: noJavaScriptAccess,
84+ ParentViewID: wv.id,
85+ })
86+ if popupWV == nil {
87+ wv.deleteSyntheticPopupState(proxyID)
88+ return
89+ }
90+ if cefPopup, ok := popupWV.(*WebView); ok {
91+ cefPopup.setPopupNoJavaScriptAccess(noJavaScriptAccess)
92+ }
93+
94+ pendingURI := ""
95+ wv.syntheticPopupMu.Lock()
96+ state := wv.syntheticPopupStateLocked(proxyID)
97+ if state.Closed {
98+ delete(wv.syntheticPopups, proxyID)
99+ wv.syntheticPopupMu.Unlock()
100+ closeSyntheticPopupWebView(popupWV)
101+ return
102+ }
103+ state.WebView = popupWV
104+ pendingURI = strings.TrimSpace(state.PendingURI)
105+ wv.syntheticPopupMu.Unlock()
106+
107+ if pendingURI == "" || pendingURI == strings.TrimSpace(targetURL) || popupWV.IsDestroyed() {
108+ return
109+ }
110+ if err := popupWV.LoadURI(context.Background(), pendingURI); err != nil {
111+ logging.FromContext(wv.ctx).Warn().
112+ Err(err).
113+ Str("proxy_id", proxyID).
114+ Str("uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
115+ Msg("cef: failed to replay synthetic popup navigation")
116+ }
117+ })
118+}
119+
120+func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) {
121+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
122+ return
123+ }
124+
125+ trimmedURI := strings.TrimSpace(targetURL)
126+ wv.syntheticPopupMu.Lock()
127+ state := wv.syntheticPopupStateLocked(proxyID)
128+ if state.Closed {
129+ wv.syntheticPopupMu.Unlock()
130+ return
131+ }
132+ state.PendingURI = trimmedURI
133+ popupWV := state.WebView
134+ wv.syntheticPopupMu.Unlock()
135+
136+ if trimmedURI == "" || popupWV == nil {
137+ return
138+ }
139+
140+ wv.runOnGTK(func() {
141+ if popupWV.IsDestroyed() {
142+ wv.syntheticPopupMu.Lock()
143+ delete(wv.syntheticPopups, proxyID)
144+ wv.syntheticPopupMu.Unlock()
145+ return
146+ }
147+ if err := popupWV.LoadURI(context.Background(), trimmedURI); err != nil {
148+ logging.FromContext(wv.ctx).Warn().
149+ Err(err).
150+ Str("proxy_id", proxyID).
151+ Str("uri", logging.TruncateURL(trimmedURI, logging.PermissionLogURLMaxLen)).
152+ Msg("cef: failed to navigate synthetic popup")
153+ }
154+ })
155+}
156+
157+func (wv *WebView) handleSyntheticPopupClose(proxyID string) {
158+ if wv == nil || proxyID == "" || wv.destroyed.Load() {
159+ return
160+ }
161+
162+ wv.syntheticPopupMu.Lock()
163+ if wv.syntheticPopups == nil {
164+ wv.syntheticPopupMu.Unlock()
165+ return
166+ }
167+ state := wv.syntheticPopups[proxyID]
168+ if state == nil {
169+ wv.syntheticPopupMu.Unlock()
170+ return
171+ }
172+ state.Closed = true
173+ popupWV := state.WebView
174+ delete(wv.syntheticPopups, proxyID)
175+ wv.syntheticPopupMu.Unlock()
176+ if popupWV == nil {
177+ return
178+ }
179+
180+ wv.runOnGTK(func() {
181+ closeSyntheticPopupWebView(popupWV)
182+ })
183+}
184+
185+func closeSyntheticPopupWebView(popupWV port.WebView) {
186+ if popupWV == nil {
187+ return
188+ }
189+ if closeable, ok := popupWV.(port.OAuthCallbackCapable); ok {
190+ closeable.Close()
191+ return
192+ }
193+ popupWV.Destroy()
194+}
A internal/infrastructure/cef/webview_popup_proxy_test.go +100 -0
@@ -0,0 +1,100 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12+)
13+
14+type closeablePopupWebViewStub struct {
15+ *portmocks.MockWebView
16+ closed bool
17+}
18+
19+func (s *closeablePopupWebViewStub) AddCloseCallback(func()) {}
20+func (s *closeablePopupWebViewStub) AddNavigationCallback(func(string)) {}
21+func (s *closeablePopupWebViewStub) Close() { s.closed = true }
22+
23+func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) {
24+ popupWV := portmocks.NewMockWebView(t)
25+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
26+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/callback").Return(nil).Once()
27+
28+ parentWV := &WebView{
29+ ctx: context.Background(),
30+ id: port.WebViewID(17),
31+ callbacks: &port.WebViewCallbacks{
32+ OnCreate: func(req port.PopupRequest) port.WebView {
33+ require.Empty(t, req.TargetURI)
34+ require.Equal(t, "_blank", req.FrameName)
35+ require.True(t, req.IsUserGesture)
36+ require.True(t, req.NoJavaScriptAccess)
37+ require.Equal(t, port.WebViewID(17), req.ParentViewID)
38+ return popupWV
39+ },
40+ },
41+ }
42+
43+ parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback")
44+ parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true, true)
45+}
46+
47+func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
48+ popupWV := portmocks.NewMockWebView(t)
49+ popupWV.EXPECT().IsDestroyed().Return(false).Once()
50+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/finish").Return(nil).Once()
51+
52+ parentWV := &WebView{
53+ ctx: context.Background(),
54+ id: port.WebViewID(23),
55+ callbacks: &port.WebViewCallbacks{
56+ OnCreate: func(req port.PopupRequest) port.WebView {
57+ require.Equal(t, "https://example.com/start", req.TargetURI)
58+ require.Equal(t, "auth-popup", req.FrameName)
59+ require.False(t, req.NoJavaScriptAccess)
60+ return popupWV
61+ },
62+ },
63+ }
64+
65+ parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true, false)
66+ parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
67+}
68+
69+func TestHandleSyntheticPopupOpen_CleansUpStateWhenPopupIsBlocked(t *testing.T) {
70+ parentWV := &WebView{
71+ ctx: context.Background(),
72+ id: port.WebViewID(28),
73+ callbacks: &port.WebViewCallbacks{
74+ OnCreate: func(port.PopupRequest) port.WebView {
75+ return nil
76+ },
77+ },
78+ }
79+
80+ parentWV.handleSyntheticPopupNavigate("popup-blocked", "https://example.com/blocked")
81+ parentWV.handleSyntheticPopupOpen("https://example.com/blocked", "_blank", "popup-blocked", true, false)
82+
83+ require.Empty(t, parentWV.syntheticPopups)
84+}
85+
86+func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {
87+ popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
88+ parentWV := &WebView{
89+ ctx: context.Background(),
90+ id: port.WebViewID(29),
91+ syntheticPopups: map[string]*syntheticPopupState{
92+ "popup-3": {WebView: popupWV},
93+ },
94+ }
95+
96+ parentWV.handleSyntheticPopupClose("popup-3")
97+
98+ require.True(t, popupWV.closed)
99+ require.Empty(t, parentWV.syntheticPopups)
100+}
M internal/infrastructure/webkit/webview.go +7 -2
@@ -30,6 +30,7 @@ import (
3030 var _ port.WebView = (*WebView)(nil)
3131 var _ port.DevToolsOpener = (*WebView)(nil)
3232 var _ port.Printer = (*WebView)(nil)
33+var _ port.PopupLifecycleCapable = (*WebView)(nil)
3334 var _ port.OAuthCallbackCapable = (*WebView)(nil)
3435
3536 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -1584,15 +1585,19 @@ func (wv *WebView) Show() {
15841585 wv.inner.SetVisible(true)
15851586 }
15861587
1588+// PrimePopupNavigation is a no-op for WebKit, which already has a native popup
1589+// browser at the time the coordinator receives popup lifecycle callbacks.
1590+func (*WebView) PrimePopupNavigation(string) {}
1591+
15871592 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1588-// It implements port.PopupCapable.
1593+// It implements port.PopupLifecycleCapable.
15891594 func (wv *WebView) SetOnReadyToShow(fn func()) {
15901595 wv.OnReadyToShow = fn
15911596 }
15921597
15931598 // SetOnClose composes fn with any existing OnClose handler so multiple callers
15941599 // can each register a close callback without overwriting one another.
1595-// It implements port.PopupCapable.
1600+// It implements port.PopupLifecycleCapable.
15961601 func (wv *WebView) SetOnClose(fn func()) {
15971602 existing := wv.OnClose
15981603 if existing == nil {
M internal/ui/coordinator/content/coordinator.go +75 -32
@@ -3,7 +3,6 @@ package content
33 import (
44 "context"
55 "sync"
6- "time"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/application/usecase"
@@ -29,8 +28,9 @@ type Coordinator struct {
2928 settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
3029 filterApplier port.FilterApplier // optional: nil if engine doesn't support
3130
32- webViews map[entity.PaneID]port.WebView
33- webViewsMu sync.RWMutex
31+ webViews map[entity.PaneID]port.WebView
32+ webViewPaneIDs map[port.WebViewID]entity.PaneID
33+ webViewsMu sync.RWMutex
3434
3535 activePaneOverride entity.PaneID
3636 activePaneOverrideMu sync.RWMutex
@@ -81,23 +81,10 @@ type Coordinator struct {
8181 // Gesture action handler for mouse button navigation
8282 gestureActionHandler input.ActionHandler
8383
84- // Popup handling
85- factory port.WebViewFactory
86- popupConfig *entity.PopupBehaviorConfig
87- pendingPopups map[port.WebViewID]*PendingPopup
88- namedPopups map[namedPopupKey]*namedPopupState
89- popupOAuth map[port.WebViewID]*popupOAuthState
90- popupRefresh map[entity.PaneID]*time.Timer
91- popupMu sync.RWMutex
92-
93- // Callback to insert popup into workspace (avoids circular dependency)
94- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
95-
96- // Callback to close a pane when popup closes
97- onClosePane func(ctx context.Context, paneID entity.PaneID) error
98-
99- // ID generator for popup panes
100- generateID func() string
84+ // Popup handling stays in a dedicated UI-layer manager so popup-specific
85+ // pane state does not bloat the main coordinator.
86+ popups *popupManager
87+ popupsOnce sync.Once
10188
10289 // Idle inhibitor for fullscreen video playback
10390 idleInhibitor port.IdleInhibitor
@@ -122,14 +109,6 @@ type pendingThemeUpdate struct {
122109 cssText string
123110 }
124111
125-type popupOAuthState struct {
126- ParentPaneID entity.PaneID
127- CallbackURI string
128- Success bool
129- Error bool
130- Seen bool
131-}
132-
133112 // PermissionActivityState represents the visible state for media permission activity.
134113 type PermissionActivityState string
135114
@@ -162,17 +141,28 @@ func NewCoordinator(
162141 zoomUC: zoomUC,
163142 permissionUC: permissionUC,
164143 webViews: make(map[entity.PaneID]port.WebView),
144+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
165145 paneTitles: make(map[entity.PaneID]string),
166146 navOrigins: make(map[entity.PaneID]string),
167147 pendingReveal: make(map[entity.PaneID]bool),
168148 pendingScriptRefresh: make(map[entity.PaneID]bool),
169149 pendingThemePanes: make(map[entity.PaneID]bool),
170150 getActiveWS: getActiveWS,
171- pendingPopups: make(map[port.WebViewID]*PendingPopup),
172- namedPopups: make(map[namedPopupKey]*namedPopupState),
173- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
174- popupRefresh: make(map[entity.PaneID]*time.Timer),
151+ popups: newPopupManager(),
152+ }
153+}
154+
155+func (c *Coordinator) ensurePopupManager() *popupManager {
156+ if c == nil {
157+ return nil
175158 }
159+ c.popupsOnce.Do(func() {
160+ if c.popups == nil {
161+ c.popups = newPopupManager()
162+ }
163+ })
164+ c.popups.ensureInitialized()
165+ return c.popups
176166 }
177167
178168 // SetOnTitleUpdated sets the callback for title changes (for history persistence).
@@ -308,7 +298,16 @@ func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
308298
309299 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
310300 c.webViewsMu.Lock()
301+ if c.webViews == nil {
302+ c.webViews = make(map[entity.PaneID]port.WebView)
303+ }
304+ if existing := c.webViews[paneID]; existing != nil && c.webViewPaneIDs != nil {
305+ delete(c.webViewPaneIDs, existing.ID())
306+ }
311307 c.webViews[paneID] = wv
308+ if wv != nil && c.webViewPaneIDs != nil {
309+ c.webViewPaneIDs[wv.ID()] = paneID
310+ }
312311 c.webViewsMu.Unlock()
313312 }
314313
@@ -317,5 +316,49 @@ func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
317316 defer c.webViewsMu.Unlock()
318317 wv := c.webViews[paneID]
319318 delete(c.webViews, paneID)
319+ if wv != nil && c.webViewPaneIDs != nil {
320+ delete(c.webViewPaneIDs, wv.ID())
321+ }
320322 return wv
321323 }
324+
325+func (c *Coordinator) paneIDByWebViewIDLocked(webViewID port.WebViewID) (entity.PaneID, bool) {
326+ paneID, ok := c.webViewPaneIDs[webViewID]
327+ return paneID, ok
328+}
329+
330+func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
331+ if webViewID == 0 {
332+ return "", false
333+ }
334+
335+ c.webViewsMu.RLock()
336+ defer c.webViewsMu.RUnlock()
337+ if paneID, ok := c.paneIDByWebViewIDLocked(webViewID); ok && paneID != "" {
338+ return paneID, true
339+ }
340+
341+ c.logger.Debug().
342+ Uint64("webview_id", uint64(webViewID)).
343+ Msg("findPaneByWebViewID: using fallback scan")
344+ for paneID, wv := range c.webViews {
345+ if wv != nil && wv.ID() == webViewID {
346+ return paneID, true
347+ }
348+ }
349+ return "", false
350+}
351+
352+func (c *Coordinator) popupHooks() popupCoordinatorHooks {
353+ return popupCoordinatorHooks{
354+ setupWebViewCallbacks: c.setupWebViewCallbacks,
355+ registerPopupWebView: c.RegisterPopupWebView,
356+ setWebView: c.setWebViewLocked,
357+ getWebView: c.getWebViewLocked,
358+ deleteWebView: c.deleteWebViewLocked,
359+ releaseWebView: c.ReleaseWebView,
360+ findPaneByWebViewID: c.findPaneByWebViewID,
361+ setupOAuthAutoClose: c.setupOAuthAutoClose,
362+ handlePopupOAuthClose: c.handlePopupOAuthClose,
363+ }
364+}
M internal/ui/coordinator/content/oauth.go +163 -53
@@ -206,10 +206,7 @@ func composeOnURIChanged(existing, next func(string)) func(string) {
206206 }
207207 }
208208
209-func composeOnLoadChanged(
210- existing func(port.LoadEvent),
211- next func(port.LoadEvent),
212-) func(port.LoadEvent) {
209+func composeOnLoadChanged(existing, next func(port.LoadEvent)) func(port.LoadEvent) {
213210 if existing == nil {
214211 return next
215212 }
@@ -227,6 +224,14 @@ func composeOnLoadChanged(
227224 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
228225 // window.close() which triggers WebKit's close signal.
229226 // A long safety timeout (30s) catches popups that get stuck.
227+func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
228+ if wv == nil {
229+ return false
230+ }
231+ opener, ok := wv.(port.PopupOpenerCapable)
232+ return ok && opener.HasActivePopupOpenerBridge()
233+}
234+
230235 func (c *Coordinator) setupOAuthAutoClose(
231236 ctx context.Context,
232237 paneID entity.PaneID,
@@ -287,8 +292,7 @@ func (c *Coordinator) setupOAuthAutoClose(
287292 })
288293 }
289294
290- requestOAuthClose := func(uri string, reason string) {
291- c.capturePopupOAuthState(popupID, uri)
295+ requestOAuthClose := func(reason string) {
292296 cancelSafetyTimer()
293297 log.Info().
294298 Str("pane", string(paneID)).
@@ -309,13 +313,38 @@ func (c *Coordinator) setupOAuthAutoClose(
309313
310314 // Start safety timer immediately.
311315 startSafetyTimer()
316+ deferCloseOnNavigation := popupUsesSyntheticOpenerSignals(wv)
317+ log.Debug().
318+ Str("pane", string(paneID)).
319+ Uint64("popup_id", uint64(popupID)).
320+ Bool("synthetic_opener_active", deferCloseOnNavigation).
321+ Msg("oauth auto-close configured")
312322
313323 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314324 oauthWV.AddNavigationCallback(func(uri string) {
315- if ShouldAutoClose(uri) {
316- requestOAuthClose(uri, "navigation")
325+ if !ShouldAutoClose(uri) {
326+ return
327+ }
328+ c.capturePopupOAuthState(popupID, uri)
329+ if deferCloseOnNavigation {
330+ cancelSafetyTimer()
331+ return
317332 }
333+ requestOAuthClose("navigation")
318334 })
335+ if opener, ok := wv.(port.PopupOpenerCapable); ok {
336+ opener.AddOpenerNavigationCallback(func(uri string) {
337+ if !ShouldAutoClose(uri) {
338+ return
339+ }
340+ c.capturePopupOAuthState(popupID, uri)
341+ requestOAuthClose("opener-navigation")
342+ })
343+ opener.AddOpenerMessageCallback(func() {
344+ c.capturePopupOAuthMessage(popupID)
345+ requestOAuthClose("opener-message")
346+ })
347+ }
319348
320349 // Cancel safety timer on any close path.
321350 oauthWV.AddCloseCallback(func() {
@@ -323,41 +352,22 @@ func (c *Coordinator) setupOAuthAutoClose(
323352 })
324353 }
325354
326-func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
327- c.popupMu.Lock()
328- defer c.popupMu.Unlock()
329- if c.popupOAuth == nil {
330- c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
331- }
332- c.popupOAuth[popupID] = &popupOAuthState{
333- ParentPaneID: parentPaneID,
334- }
355+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
356+ c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
335357 }
336358
337359 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
338- c.popupMu.Lock()
339- defer c.popupMu.Unlock()
340-
341- state, ok := c.popupOAuth[popupID]
342- if !ok {
343- return
344- }
360+ c.ensurePopupManager().capturePopupOAuthState(popupID, uri)
361+}
345362
346- state.Seen = true
347- state.CallbackURI = uri
348- state.Success = IsOAuthSuccess(uri)
349- state.Error = IsOAuthError(uri)
363+func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
364+ c.ensurePopupManager().capturePopupOAuthMessage(popupID)
350365 }
351366
352367 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
353368 log := logging.FromContext(ctx)
354369
355- c.popupMu.Lock()
356- state, ok := c.popupOAuth[popupID]
357- if ok {
358- delete(c.popupOAuth, popupID)
359- }
360- c.popupMu.Unlock()
370+ state, ok := c.ensurePopupManager().takePopupOAuthState(popupID)
361371
362372 if !ok || state == nil || !state.Seen {
363373 return
@@ -370,42 +380,78 @@ func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.We
370380 Bool("oauth_error", state.Error).
371381 Msg("popup oauth result captured on close")
372382
373- if !state.Success || state.ParentPaneID == "" {
383+ if state.ParentPaneID == "" {
374384 return
375385 }
376386
377- c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
387+ if !state.Success {
388+ return
389+ }
390+
391+ c.scheduleParentPaneOAuthResume(ctx, state.ParentPaneID, popupID, state.ParentURIAtOpen, state.CallbackURI)
392+}
393+
394+// oauthParentResumeGraceRetries gives the parent pane up to
395+// oauthParentResumeGraceRetries * oauthParentRefreshDebounce (~1s with the
396+// current 200ms debounce) to finish its own in-flight same-site OAuth
397+// navigation before scheduleParentPaneOAuthResume falls back to Reload(). Five
398+// retries keeps the UI responsive without racing the natural callback handoff.
399+const oauthParentResumeGraceRetries = 5
400+
401+func (c *Coordinator) scheduleParentPaneOAuthResume(
402+ ctx context.Context,
403+ parentPaneID entity.PaneID,
404+ popupID port.WebViewID,
405+ parentURIAtOpen, callbackURI string,
406+) {
407+ c.scheduleParentPaneOAuthResumeAttempt(
408+ ctx,
409+ parentPaneID,
410+ popupID,
411+ parentURIAtOpen,
412+ callbackURI,
413+ oauthParentResumeGraceRetries,
414+ )
378415 }
379416
380-func (c *Coordinator) scheduleParentPaneRefresh(
417+func (c *Coordinator) scheduleParentPaneOAuthResumeAttempt(
381418 ctx context.Context,
382419 parentPaneID entity.PaneID,
383420 popupID port.WebViewID,
421+ parentURIAtOpen, callbackURI string,
422+ remainingGrace int,
384423 ) {
385- c.popupMu.Lock()
386- if c.popupRefresh == nil {
387- c.popupRefresh = make(map[entity.PaneID]*time.Timer)
388- }
389- if existing := c.popupRefresh[parentPaneID]; existing != nil {
390- existing.Stop()
391- }
392- c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
393- c.popupMu.Lock()
394- delete(c.popupRefresh, parentPaneID)
395- c.popupMu.Unlock()
424+ c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() {
396425 cb := glib.SourceFunc(func(_ uintptr) bool {
397- c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
426+ c.resumeParentPaneAfterOAuthAttempt(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI, remainingGrace)
398427 return false
399428 })
400429 glib.IdleAdd(&cb, 0)
401430 })
402- c.popupMu.Unlock()
403431 }
404432
405-func (c *Coordinator) refreshPaneAfterOAuth(
433+func (c *Coordinator) resumeParentPaneAfterOAuth(
406434 ctx context.Context,
407435 parentPaneID entity.PaneID,
408436 popupID port.WebViewID,
437+ parentURIAtOpen, callbackURI string,
438+) {
439+ c.resumeParentPaneAfterOAuthAttempt(
440+ ctx,
441+ parentPaneID,
442+ popupID,
443+ parentURIAtOpen,
444+ callbackURI,
445+ oauthParentResumeGraceRetries,
446+ )
447+}
448+
449+func (c *Coordinator) resumeParentPaneAfterOAuthAttempt(
450+ ctx context.Context,
451+ parentPaneID entity.PaneID,
452+ popupID port.WebViewID,
453+ parentURIAtOpen, callbackURI string,
454+ remainingGrace int,
409455 ) {
410456 log := logging.FromContext(ctx)
411457 wv := c.getWebViewLocked(parentPaneID)
@@ -413,7 +459,38 @@ func (c *Coordinator) refreshPaneAfterOAuth(
413459 log.Debug().
414460 Str("parent_pane_id", string(parentPaneID)).
415461 Uint64("popup_id", uint64(popupID)).
416- Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
462+ Msg("skipping parent pane oauth resume: parent webview unavailable")
463+ return
464+ }
465+
466+ parentURI := strings.TrimSpace(wv.URI())
467+ if parentURIAtOpen != "" && parentURI != "" && parentURI != parentURIAtOpen {
468+ log.Info().
469+ Str("parent_pane_id", string(parentPaneID)).
470+ Uint64("popup_id", uint64(popupID)).
471+ Str("parent_uri_at_open", logging.TruncateURL(parentURIAtOpen, logURLMaxLen)).
472+ Str("current_parent_uri", logging.TruncateURL(parentURI, logURLMaxLen)).
473+ Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
474+ Msg("skipping parent pane oauth resume: parent already navigated")
475+ return
476+ }
477+
478+ if shouldGraceWaitForParentPaneOAuthResume(parentURIAtOpen, parentURI, callbackURI, remainingGrace) {
479+ log.Debug().
480+ Str("parent_pane_id", string(parentPaneID)).
481+ Uint64("popup_id", uint64(popupID)).
482+ Int("remaining_grace", remainingGrace).
483+ Str("parent_uri_at_open", logging.TruncateURL(parentURIAtOpen, logURLMaxLen)).
484+ Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
485+ Msg("deferring parent pane oauth resume to allow in-flight same-site navigation to settle")
486+ c.scheduleParentPaneOAuthResumeAttempt(
487+ ctx,
488+ parentPaneID,
489+ popupID,
490+ parentURIAtOpen,
491+ callbackURI,
492+ remainingGrace-1,
493+ )
417494 return
418495 }
419496
@@ -429,5 +506,38 @@ func (c *Coordinator) refreshPaneAfterOAuth(
429506 log.Info().
430507 Str("parent_pane_id", string(parentPaneID)).
431508 Uint64("popup_id", uint64(popupID)).
432- Msg("refreshed parent pane after oauth popup success")
509+ Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
510+ Msg("refreshed parent pane after oauth popup close")
511+}
512+
513+func shouldGraceWaitForParentPaneOAuthResume(
514+ parentURIAtOpen, currentParentURI, callbackURI string,
515+ remainingGrace int,
516+) bool {
517+ if remainingGrace <= 0 {
518+ return false
519+ }
520+ if parentURIAtOpen == "" || currentParentURI == "" || callbackURI == "" {
521+ return false
522+ }
523+ if currentParentURI != parentURIAtOpen {
524+ return false
525+ }
526+ return sameOAuthResumeSite(parentURIAtOpen, callbackURI)
527+}
528+
529+func sameOAuthResumeSite(a, b string) bool {
530+ hostA := normalizeOAuthResumeHost(a)
531+ hostB := normalizeOAuthResumeHost(b)
532+ return hostA != "" && hostA == hostB
533+}
534+
535+func normalizeOAuthResumeHost(raw string) string {
536+ parsed, err := neturl.Parse(raw)
537+ if err != nil {
538+ return ""
539+ }
540+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
541+ host = strings.TrimPrefix(host, "www.")
542+ return host
433543 }
M internal/ui/coordinator/content/oauth_test.go +181 -24
@@ -6,11 +6,66 @@ import (
66 "time"
77
88 "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
911
1012 "github.com/bnema/dumber/internal/application/port"
13+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1114 "github.com/bnema/dumber/internal/domain/entity"
1215 )
1316
17+type popupOpenerBridgeStateStub struct {
18+ *portmocks.MockWebView
19+ active bool
20+}
21+
22+func (s *popupOpenerBridgeStateStub) EnablePopupOpenerBridge(port.WebView, bool) {}
23+func (s *popupOpenerBridgeStateStub) AddOpenerMessageCallback(func()) {}
24+func (s *popupOpenerBridgeStateStub) AddOpenerNavigationCallback(func(string)) {
25+}
26+func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
27+
28+type popupOAuthAutoCloseWebViewStub struct {
29+ *portmocks.MockWebView
30+ active bool
31+ navigationCallbacks []func(string)
32+ closeCallbacks []func()
33+ openerMessageCallbacks []func()
34+ openerNavigateCallbacks []func(string)
35+}
36+
37+func (s *popupOAuthAutoCloseWebViewStub) AddNavigationCallback(fn func(string)) {
38+ s.navigationCallbacks = append(s.navigationCallbacks, fn)
39+}
40+
41+func (s *popupOAuthAutoCloseWebViewStub) AddCloseCallback(fn func()) {
42+ s.closeCallbacks = append(s.closeCallbacks, fn)
43+}
44+
45+func (*popupOAuthAutoCloseWebViewStub) Close() {}
46+
47+func (*popupOAuthAutoCloseWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
48+
49+func (s *popupOAuthAutoCloseWebViewStub) AddOpenerMessageCallback(fn func()) {
50+ s.openerMessageCallbacks = append(s.openerMessageCallbacks, fn)
51+}
52+
53+func (s *popupOAuthAutoCloseWebViewStub) AddOpenerNavigationCallback(fn func(string)) {
54+ s.openerNavigateCallbacks = append(s.openerNavigateCallbacks, fn)
55+}
56+
57+func (s *popupOAuthAutoCloseWebViewStub) HasActivePopupOpenerBridge() bool { return s.active }
58+
59+func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
60+ wv := &popupOpenerBridgeStateStub{MockWebView: portmocks.NewMockWebView(t), active: true}
61+ assert.True(t, popupUsesSyntheticOpenerSignals(wv))
62+}
63+
64+func TestPopupUsesSyntheticOpenerSignals_RejectsPlainWebView(t *testing.T) {
65+ wv := portmocks.NewMockWebView(t)
66+ assert.False(t, popupUsesSyntheticOpenerSignals(wv))
67+}
68+
1469 func TestComposeOnClose_Order(t *testing.T) {
1570 var calls []string
1671
@@ -47,52 +102,154 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
47102 assert.Equal(t, []string{"existing", "next"}, calls)
48103 }
49104
50-func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
105+func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
106+ popupID := port.WebViewID(100)
107+ c := &Coordinator{
108+ popups: newPopupManager(),
109+ }
110+ c.popups.popupOAuth[popupID] = &popupOAuthState{
111+ ParentPaneID: entity.PaneID("parent-pane"),
112+ }
113+
114+ c.capturePopupOAuthMessage(popupID)
115+
116+ state := c.popups.popupOAuth[popupID]
117+ assert.True(t, state.Seen)
118+ assert.True(t, state.Success)
119+ assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
120+}
121+
122+func TestSetupOAuthAutoClose_OpenerNavigationIgnoresNonTerminalURI(t *testing.T) {
123+ popupID := port.WebViewID(1000)
124+ wv := &popupOAuthAutoCloseWebViewStub{
125+ MockWebView: portmocks.NewMockWebView(t),
126+ active: true,
127+ }
128+ c := &Coordinator{popups: newPopupManager()}
129+ c.trackOAuthPopup(popupID, entity.PaneID("parent-pane"), "https://example.com/start")
130+
131+ c.setupOAuthAutoClose(context.Background(), entity.PaneID("popup-pane"), popupID, wv)
132+ require.Len(t, wv.openerNavigateCallbacks, 1)
133+
134+ wv.openerNavigateCallbacks[0]("https://example.com/intermediate")
135+
136+ state, ok := c.popups.popupOAuth[popupID]
137+ require.True(t, ok)
138+ assert.False(t, state.Seen)
139+ assert.Empty(t, state.CallbackURI)
140+
141+ for _, fn := range wv.closeCallbacks {
142+ fn()
143+ }
144+}
145+
146+func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
51147 parentPaneID := entity.PaneID("parent-pane")
52148 popupID := port.WebViewID(101)
53149
54150 c := &Coordinator{
55- webViews: make(map[entity.PaneID]port.WebView),
56- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
57- popupRefresh: make(map[entity.PaneID]*time.Timer),
151+ webViews: make(map[entity.PaneID]port.WebView),
152+ popups: newPopupManager(),
58153 }
59154
60- c.trackOAuthPopup(popupID, parentPaneID)
61- c.capturePopupOAuthState(popupID, "https://app/callback?code=123")
155+ c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")
156+ c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")
62157 c.handlePopupOAuthClose(context.Background(), popupID)
63158
64- c.popupMu.RLock()
65- _, exists := c.popupOAuth[popupID]
66- refreshTimer := c.popupRefresh[parentPaneID]
67- c.popupMu.RUnlock()
159+ c.popups.mu.RLock()
160+ _, exists := c.popups.popupOAuth[popupID]
161+ refreshTimer := c.popups.popupRefresh[parentPaneID]
162+ c.popups.mu.RUnlock()
68163
69164 assert.False(t, exists, "oauth state should be removed after close handling")
70- assert.NotNil(t, refreshTimer, "successful oauth close should schedule parent refresh")
165+ assert.NotNil(t, refreshTimer, "oauth callback should schedule parent resume")
71166
72167 waitFor(t, time.Second, func() bool {
73- c.popupMu.RLock()
74- defer c.popupMu.RUnlock()
75- return c.popupRefresh[parentPaneID] == nil
168+ c.popups.mu.RLock()
169+ defer c.popups.mu.RUnlock()
170+ return c.popups.popupRefresh[parentPaneID] == nil
76171 })
77172 }
78173
79-func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
174+func TestResumeParentPaneAfterOAuth_UnchangedParentDefersSameSiteFallbackReload(t *testing.T) {
80175 parentPaneID := entity.PaneID("parent-pane")
81176 popupID := port.WebViewID(102)
177+ parentWV := portmocks.NewMockWebView(t)
178+ parentWV.EXPECT().IsDestroyed().Return(false).Once()
179+ parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
82180
83181 c := &Coordinator{
84- webViews: make(map[entity.PaneID]port.WebView),
85- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
86- popupRefresh: make(map[entity.PaneID]*time.Timer),
182+ webViews: map[entity.PaneID]port.WebView{
183+ parentPaneID: parentWV,
184+ },
185+ popups: newPopupManager(),
87186 }
88187
89- c.trackOAuthPopup(popupID, parentPaneID)
90- c.capturePopupOAuthState(popupID, "https://app/callback?error=access_denied")
91- c.handlePopupOAuthClose(context.Background(), popupID)
188+ c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123")
189+
190+ c.popups.mu.Lock()
191+ refreshTimer := c.popups.popupRefresh[parentPaneID]
192+ if refreshTimer != nil {
193+ refreshTimer.Stop()
194+ delete(c.popups.popupRefresh, parentPaneID)
195+ }
196+ c.popups.mu.Unlock()
197+
198+ assert.NotNil(t, refreshTimer, "same-site popup callback should get a grace retry before forcing reload")
199+}
200+
201+func TestResumeParentPaneAfterOAuth_ChangedParentSkipsIntervention(t *testing.T) {
202+ parentPaneID := entity.PaneID("parent-pane")
203+ popupID := port.WebViewID(103)
204+ parentWV := portmocks.NewMockWebView(t)
205+ parentWV.EXPECT().IsDestroyed().Return(false).Once()
206+ parentWV.EXPECT().URI().Return("https://www.notion.so/oauth2callback?state=abc").Once()
207+
208+ c := &Coordinator{
209+ webViews: map[entity.PaneID]port.WebView{
210+ parentPaneID: parentWV,
211+ },
212+ popups: newPopupManager(),
213+ }
214+
215+ c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123")
216+ parentWV.AssertNotCalled(t, "Reload", mock.Anything)
217+}
218+
219+func TestResumeParentPaneAfterOAuthAttempt_GraceExhaustedFallsBackToReload(t *testing.T) {
220+ parentPaneID := entity.PaneID("parent-pane")
221+ popupID := port.WebViewID(104)
222+ parentWV := portmocks.NewMockWebView(t)
223+ parentWV.EXPECT().IsDestroyed().Return(false).Once()
224+ parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
225+ parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once()
226+
227+ c := &Coordinator{
228+ webViews: map[entity.PaneID]port.WebView{
229+ parentPaneID: parentWV,
230+ },
231+ popups: newPopupManager(),
232+ }
233+
234+ c.resumeParentPaneAfterOAuthAttempt(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123", 0)
235+}
236+
237+func TestResumeParentPaneAfterOAuth_DifferentDomainFallsBackToReload(t *testing.T) {
238+ parentPaneID := entity.PaneID("parent-pane")
239+ popupID := port.WebViewID(105)
240+ parentWV := portmocks.NewMockWebView(t)
241+ parentWV.EXPECT().IsDestroyed().Return(false).Once()
242+ parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
243+ parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once()
244+
245+ c := &Coordinator{
246+ webViews: map[entity.PaneID]port.WebView{
247+ parentPaneID: parentWV,
248+ },
249+ popups: newPopupManager(),
250+ }
92251
93- c.popupMu.RLock()
94- defer c.popupMu.RUnlock()
95- assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")
252+ c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://accounts.example.com/callback?code=123")
96253 }
97254
98255 func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {
M internal/ui/coordinator/content/popup.go +8 -469
@@ -2,7 +2,6 @@ package content
22
33 import (
44 "context"
5- "fmt"
65 "time"
76
87 "github.com/bnema/dumber/internal/application/port"
@@ -92,69 +91,6 @@ func isReusableNamedPopupFrame(frameName string) bool {
9291 return frameName != "" && frameName != "_blank"
9392 }
9493
95-func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
96- if !isReusableNamedPopupFrame(frameName) {
97- return nil, false
98- }
99-
100- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
101-
102- c.popupMu.RLock()
103- state, ok := c.namedPopups[key]
104- c.popupMu.RUnlock()
105- if !ok || state == nil || state.WebView == nil {
106- return nil, false
107- }
108-
109- if state.WebView.IsDestroyed() {
110- c.popupMu.Lock()
111- if current, ok := c.namedPopups[key]; ok && current == state {
112- delete(c.namedPopups, key)
113- }
114- c.popupMu.Unlock()
115- return nil, false
116- }
117-
118- return state, true
119-}
120-
121-func (c *Coordinator) storeReusableNamedPopup(
122- parentPaneID entity.PaneID,
123- frameName string,
124- wv port.WebView,
125-) {
126- if !isReusableNamedPopupFrame(frameName) || wv == nil {
127- return
128- }
129-
130- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
131-
132- c.popupMu.Lock()
133- if c.namedPopups == nil {
134- c.namedPopups = make(map[namedPopupKey]*namedPopupState)
135- }
136- c.namedPopups[key] = &namedPopupState{WebView: wv}
137- c.popupMu.Unlock()
138-}
139-
140-func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
141- c.popupMu.Lock()
142- if pending, ok := c.pendingPopups[popupID]; ok && pending != nil {
143- pending.TargetURI = targetURI
144- }
145- c.popupMu.Unlock()
146-}
147-
148-func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
149- c.popupMu.Lock()
150- for key, state := range c.namedPopups {
151- if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
152- delete(c.namedPopups, key)
153- }
154- }
155- c.popupMu.Unlock()
156-}
157-
15894 // InsertPopupInput contains the data needed to insert a popup into the workspace.
15995 type InsertPopupInput struct {
16096 // ParentPaneID is the pane that spawned this popup.
@@ -213,19 +149,17 @@ func (c *Coordinator) SetPopupConfig(
213149 popupConfig *entity.PopupBehaviorConfig,
214150 generateID func() string,
215151 ) {
216- c.factory = factory
217- c.popupConfig = popupConfig
218- c.generateID = generateID
152+ c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
219153 }
220154
221155 // SetOnInsertPopup sets the callback to insert popups into the workspace.
222156 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
223- c.onInsertPopup = fn
157+ c.ensurePopupManager().setOnInsertPopup(fn)
224158 }
225159
226160 // SetOnClosePane sets the callback to close a pane when its popup closes.
227161 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
228- c.onClosePane = fn
162+ c.ensurePopupManager().setOnClosePane(fn)
229163 }
230164
231165 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
@@ -245,33 +179,11 @@ func (c *Coordinator) buildPopupCreateHandler(
245179 }
246180 }
247181
248-// createPopupPane creates a new pane entity for a popup window.
249-func (c *Coordinator) createPopupPane(
250- popupID port.WebViewID,
251- parentPaneID entity.PaneID,
252- targetURI string,
253-) (entity.PaneID, *entity.Pane) {
254- var paneID entity.PaneID
255- if c.generateID != nil {
256- paneID = entity.PaneID(c.generateID())
257- } else {
258- paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
259- }
260-
261- popupPane := entity.NewPane(paneID)
262- popupPane.WindowType = entity.WindowPopup
263- popupPane.IsRelated = true
264- popupPane.ParentPaneID = &parentPaneID
265- popupPane.URI = targetURI
266-
267- return paneID, popupPane
268-}
269-
270-// handlePopupCreate handles the WebKit "create" signal for popup windows.
271-// Returns a new related WebView if popup is allowed, nil to block.
182+// handlePopupCreate handles a popup request from the current WebView.
183+// Returns a WebView if popup handling is allowed, nil to block.
272184 //
273185 // IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
274-// signal handler returns. Otherwise WebKit won't establish window.opener.
186+// signal handler returns for engines that require native popup lifecycles.
275187 // The WebView stays hidden until ready-to-show signal is received.
276188 func (c *Coordinator) handlePopupCreate(
277189 ctx context.Context,
@@ -279,384 +191,11 @@ func (c *Coordinator) handlePopupCreate(
279191 parentWV port.WebView,
280192 req port.PopupRequest,
281193 ) port.WebView {
282- log := logging.FromContext(ctx)
283-
284- log.Debug().
285- Str("parent_pane", string(parentPaneID)).
286- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
287- Str("frame_name", req.FrameName).
288- Bool("user_gesture", req.IsUserGesture).
289- Msg("popup create request")
290-
291- // Check if popups are enabled
292- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
293- log.Debug().Msg("popups disabled by config, blocking")
294- return nil
295- }
296-
297- // Check if factory is available
298- if c.factory == nil {
299- log.Warn().Msg("no webview factory, cannot create popup")
300- return nil
301- }
302-
303- parentID := parentWV.ID()
304- if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
305- return reused
306- }
307-
308- // Create related WebView for session sharing (created hidden)
309- popupWV, err := c.factory.CreateRelated(ctx, parentID)
310- if err != nil {
311- log.Error().Err(err).Msg("failed to create related webview for popup")
312- return nil
313- }
314-
315- // Detect popup type
316- popupType := DetectPopupType(req.FrameName)
317- popupID := popupWV.ID()
318-
319- // Determine behavior from config
320- behavior := GetBehavior(popupType, c.popupConfig)
321- placement := "right"
322- if c.popupConfig != nil {
323- placement = c.popupConfig.Placement
324- }
325-
326- // Create popup pane entity
327- paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
328-
329- return c.finishPopupCreate(ctx, parentPaneID, parentID, popupID, popupWV, popupPane, paneID, popupType, behavior, placement, req)
330-}
331-
332-func (c *Coordinator) reuseNamedPopup(
333- ctx context.Context,
334- parentPaneID entity.PaneID,
335- frameName string,
336- targetURI string,
337-) (port.WebView, bool) {
338- log := logging.FromContext(ctx)
339-
340- if existing, ok := c.lookupReusableNamedPopup(parentPaneID, frameName); ok {
341- c.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
342- if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
343- log.Warn().Err(err).
344- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
345- Msg("failed to load target URI in reused popup")
346- }
347- log.Info().
348- Str("parent_pane", string(parentPaneID)).
349- Str("frame_name", frameName).
350- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
351- Msg("reused named popup")
352- return existing.WebView, true
353- }
354-
355- return nil, false
356-}
357-
358-func (c *Coordinator) finishPopupCreate(
359- ctx context.Context,
360- parentPaneID entity.PaneID,
361- parentID port.WebViewID,
362- popupID port.WebViewID,
363- popupWV port.WebView,
364- popupPane *entity.Pane,
365- paneID entity.PaneID,
366- popupType PopupType,
367- behavior entity.PopupBehavior,
368- placement string,
369- req port.PopupRequest,
370-) port.WebView {
371- log := logging.FromContext(ctx)
372- hasConfig := c.popupConfig != nil
373- oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
374- isOAuth := IsOAuthURL(req.TargetURI)
375-
376- log.Debug().
377- Bool("has_config", hasConfig).
378- Bool("oauth_enabled", oauthEnabled).
379- Bool("is_oauth", isOAuth).
380- Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
381- Msg("popup OAuth check")
382-
383- // Insert into workspace IMMEDIATELY (WebView stays hidden)
384- // This is required for WebKit to establish window.opener relationship
385- if c.onInsertPopup != nil {
386- popupInput := InsertPopupInput{
387- ParentPaneID: parentPaneID,
388- PopupPane: popupPane,
389- WebView: popupWV,
390- Behavior: behavior,
391- Placement: placement,
392- PopupType: popupType,
393- TargetURI: req.TargetURI,
394- }
395-
396- if err := c.onInsertPopup(ctx, popupInput); err != nil {
397- log.Error().Err(err).Msg("failed to insert popup into workspace")
398- popupWV.Destroy()
399- return nil
400- }
401- }
402-
403- // Register WebView in our map (after successful insertion)
404- c.setWebViewLocked(paneID, popupWV)
405- c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
406-
407- // Setup standard callbacks (after successful insertion to avoid leak)
408- c.setupWebViewCallbacks(ctx, paneID, popupWV)
409-
410- // Setup OAuth auto-close if configured
411- if hasConfig && oauthEnabled && isOAuth {
412- popupPane.AutoClose = true
413- c.trackOAuthPopup(popupID, parentPaneID)
414- c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
415- log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
416- }
417-
418- // Store pending popup for ready-to-show handling (just visibility now)
419- pending := &PendingPopup{
420- PaneID: paneID,
421- WebView: popupWV,
422- ParentPaneID: parentPaneID,
423- ParentWebViewID: parentID,
424- TargetURI: req.TargetURI,
425- FrameName: req.FrameName,
426- IsUserGesture: req.IsUserGesture,
427- PopupType: popupType,
428- CreatedAt: time.Now(),
429- }
430-
431- c.popupMu.Lock()
432- c.pendingPopups[popupID] = pending
433- c.popupMu.Unlock()
434-
435- // Wire ready-to-show and close signals via the PopupCapable interface.
436- if pc, ok := popupWV.(port.PopupCapable); ok {
437- pc.SetOnReadyToShow(func() {
438- c.handlePopupReadyToShow(ctx, popupID)
439- })
440- pc.SetOnClose(func() {
441- c.handlePopupClose(ctx, popupID)
442- })
443- log.Info().
444- Uint64("popup_id", uint64(popupID)).
445- Str("pane_id", string(paneID)).
446- Str("popup_type", popupType.String()).
447- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
448- Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
449- } else {
450- // Engine does not support PopupCapable (e.g. CEF OSR where we create
451- // an independent browser, not a real CEF popup). The WebView is ready
452- // immediately — fire ready-to-show inline.
453- log.Info().
454- Uint64("popup_id", uint64(popupID)).
455- Str("pane_id", string(paneID)).
456- Str("popup_type", popupType.String()).
457- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
458- Msg("popup inserted, immediately ready (no PopupCapable)")
459- c.handlePopupReadyToShow(ctx, popupID)
460- }
461-
462- return popupWV
463-}
464-
465-// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
466-// The popup WebView was already inserted into the workspace (hidden) during
467-// the create signal. This handler just makes it visible.
468-func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
469- log := logging.FromContext(ctx)
470-
471- // Get pending popup
472- c.popupMu.Lock()
473- pending, ok := c.pendingPopups[popupID]
474- if ok {
475- delete(c.pendingPopups, popupID)
476- }
477- c.popupMu.Unlock()
478-
479- if !ok || pending == nil {
480- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
481- return
482- }
483-
484- log.Debug().
485- Uint64("popup_id", uint64(popupID)).
486- Str("popup_type", pending.PopupType.String()).
487- Msg("popup ready to show - making visible")
488-
489- // Make the WebView visible now that it's ready.
490- if pending.WebView != nil {
491- if pc, ok := pending.WebView.(port.PopupCapable); ok {
492- pc.Show()
493- }
494- // For engines that create independent browsers (not real popups),
495- // the WebView has no pending navigation — load the target URI.
496- if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
497- if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
498- log.Warn().Err(err).
499- Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
500- Msg("failed to load target URI in popup")
501- }
502- }
503- }
504-
505- log.Info().
506- Uint64("popup_id", uint64(popupID)).
507- Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
508- Msg("popup now visible")
509-}
510-
511-// handlePopupClose handles the WebKit "close" signal for popup windows.
512-func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
513- log := logging.FromContext(ctx)
514- log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
515-
516- // Check if still pending (never shown)
517- c.popupMu.Lock()
518- pending, wasPending := c.pendingPopups[popupID]
519- if wasPending {
520- delete(c.pendingPopups, popupID)
521- }
522- c.popupMu.Unlock()
523-
524- if wasPending && pending != nil {
525- if c.onClosePane != nil {
526- if err := c.onClosePane(ctx, pending.PaneID); err != nil {
527- log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
528- }
529- }
530- c.handlePopupOAuthClose(ctx, popupID)
531- c.clearReusableNamedPopupByWebViewID(popupID)
532- c.ReleaseWebView(ctx, pending.PaneID)
533- log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
534- return
535- }
536-
537- // Find pane by WebView ID
538- var paneID entity.PaneID
539- c.webViewsMu.RLock()
540- for pid, wv := range c.webViews {
541- if wv != nil && wv.ID() == popupID {
542- paneID = pid
543- break
544- }
545- }
546- c.webViewsMu.RUnlock()
547-
548- if paneID == "" {
549- c.handlePopupOAuthClose(ctx, popupID)
550- c.clearReusableNamedPopupByWebViewID(popupID)
551- log.Warn().Msg("popup close: could not find pane for webview")
552- return
553- }
554-
555- // Close the pane in workspace (this removes the UI element)
556- if c.onClosePane != nil {
557- if err := c.onClosePane(ctx, paneID); err != nil {
558- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
559- }
560- }
561-
562- c.handlePopupOAuthClose(ctx, popupID)
563- c.clearReusableNamedPopupByWebViewID(popupID)
564-
565- // Release the WebView (this will clean up tracking)
566- c.ReleaseWebView(ctx, paneID)
567-
568- log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
194+ return c.ensurePopupManager().handlePopupCreate(ctx, c.popupHooks(), parentPaneID, parentWV, req)
569195 }
570196
571197 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
572198 // Opens the link in a new pane using blank_target_behavior config.
573199 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
574- log := logging.FromContext(ctx)
575-
576- log.Info().
577- Str("parent_pane", string(parentPaneID)).
578- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
579- Msg("middle-click/ctrl+click on link")
580-
581- // Check if popups are enabled
582- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
583- log.Debug().Msg("popups disabled by config, ignoring middle-click")
584- return false
585- }
586-
587- // Check if factory is available
588- if c.factory == nil {
589- log.Warn().Msg("no webview factory, cannot handle middle-click")
590- return false
591- }
592-
593- // Create a new WebView (regular, not related - just opening a link)
594- newWV, err := c.factory.Create(ctx)
595- if err != nil {
596- log.Error().Err(err).Msg("failed to create webview for middle-click")
597- return false
598- }
599-
600- // Generate pane ID
601- var paneID entity.PaneID
602- if c.generateID != nil {
603- paneID = entity.PaneID(c.generateID())
604- } else {
605- paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
606- }
607-
608- // Create pane entity - uses blank_target_behavior
609- newPane := entity.NewPane(paneID)
610- newPane.WindowType = entity.WindowPopup
611- newPane.URI = uri
612-
613- // Register WebView
614- c.setWebViewLocked(paneID, newWV)
615-
616- // Setup callbacks
617- c.setupWebViewCallbacks(ctx, paneID, newWV)
618-
619- // Get behavior from config (same as _blank links)
620- behavior := entity.PopupBehaviorStacked // default
621- if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
622- behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
623- }
624- placement := "right"
625- if c.popupConfig != nil {
626- placement = c.popupConfig.Placement
627- }
628-
629- // Request insertion into workspace
630- if c.onInsertPopup != nil {
631- popupInput := InsertPopupInput{
632- ParentPaneID: parentPaneID,
633- PopupPane: newPane,
634- WebView: newWV,
635- Behavior: behavior,
636- Placement: placement,
637- PopupType: PopupTypeTab, // Treat like _blank
638- TargetURI: uri,
639- }
640-
641- if err := c.onInsertPopup(ctx, popupInput); err != nil {
642- log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
643- // Clean up on failure — use the locking helper to avoid a data race.
644- c.deleteWebViewLocked(paneID)
645- newWV.Destroy()
646- return false
647- }
648- }
649-
650- // Load the URI after insertion
651- if err := newWV.LoadURI(ctx, uri); err != nil {
652- log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
653- }
654-
655- log.Info().
656- Str("pane_id", string(paneID)).
657- Str("behavior", string(behavior)).
658- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
659- Msg("middle-click link opened in new pane")
660-
661- return true
200+ return c.ensurePopupManager().handleLinkMiddleClick(ctx, c.popupHooks(), parentPaneID, uri)
662201 }
A internal/ui/coordinator/content/popup_manager.go +829 -0
@@ -0,0 +1,829 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+// popupManager owns popup-specific coordinator state. It stays in the UI
18+// adapter layer so popup pane bookkeeping and workspace orchestration do not
19+// leak into the application/usecase or domain layers.
20+type popupManager struct {
21+ factory port.WebViewFactory
22+ popupConfig *entity.PopupBehaviorConfig
23+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
25+ generatePaneID func() string
26+ pendingPopups map[port.WebViewID]*PendingPopup
27+ namedPopups map[namedPopupKey]*namedPopupState
28+ popupOAuth map[port.WebViewID]*popupOAuthState
29+ popupRefresh map[entity.PaneID]*time.Timer
30+ relatedPopupUnsupported bool
31+ relatedPopupSupportDetected bool
32+ mu sync.RWMutex
33+}
34+
35+type popupOAuthState struct {
36+ ParentPaneID entity.PaneID
37+ ParentURIAtOpen string
38+ CallbackURI string
39+ Success bool
40+ Error bool
41+ Seen bool
42+}
43+
44+type popupCreateContext struct {
45+ ParentPaneID entity.PaneID
46+ ParentWebViewID port.WebViewID
47+ ParentURIAtOpen string
48+ PopupID port.WebViewID
49+ PopupWebView port.WebView
50+ PopupPane *entity.Pane
51+ PopupPaneID entity.PaneID
52+ PopupType PopupType
53+ Behavior entity.PopupBehavior
54+ Placement string
55+ Request port.PopupRequest
56+}
57+
58+type popupCoordinatorHooks struct {
59+ setupWebViewCallbacks func(context.Context, entity.PaneID, port.WebView)
60+ registerPopupWebView func(entity.PaneID, port.WebView)
61+ setWebView func(entity.PaneID, port.WebView)
62+ getWebView func(entity.PaneID) port.WebView
63+ deleteWebView func(entity.PaneID) port.WebView
64+ releaseWebView func(context.Context, entity.PaneID)
65+ findPaneByWebViewID func(port.WebViewID) (entity.PaneID, bool)
66+ setupOAuthAutoClose func(context.Context, entity.PaneID, port.WebViewID, port.WebView)
67+ handlePopupOAuthClose func(context.Context, port.WebViewID)
68+}
69+
70+func newPopupManager() *popupManager {
71+ pm := &popupManager{}
72+ pm.ensureInitialized()
73+ return pm
74+}
75+
76+func (pm *popupManager) ensureInitialized() {
77+ if pm == nil {
78+ return
79+ }
80+ pm.mu.Lock()
81+ defer pm.mu.Unlock()
82+ if pm.pendingPopups == nil {
83+ pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
84+ }
85+ if pm.namedPopups == nil {
86+ pm.namedPopups = make(map[namedPopupKey]*namedPopupState)
87+ }
88+ if pm.popupOAuth == nil {
89+ pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
90+ }
91+ if pm.popupRefresh == nil {
92+ pm.popupRefresh = make(map[entity.PaneID]*time.Timer)
93+ }
94+}
95+
96+func (pm *popupManager) setConfig(
97+ factory port.WebViewFactory,
98+ popupConfig *entity.PopupBehaviorConfig,
99+ generateID func() string,
100+) {
101+ if pm == nil {
102+ return
103+ }
104+ pm.ensureInitialized()
105+ pm.factory = factory
106+ pm.popupConfig = popupConfig
107+ pm.generatePaneID = generateID
108+
109+ pm.mu.Lock()
110+ pm.relatedPopupUnsupported = false
111+ pm.relatedPopupSupportDetected = false
112+ pm.mu.Unlock()
113+}
114+
115+func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
116+ if pm == nil {
117+ return
118+ }
119+ pm.onInsertPopup = fn
120+}
121+
122+func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
123+ if pm == nil {
124+ return
125+ }
126+ pm.onClosePane = fn
127+}
128+
129+func (pm *popupManager) createPopupPane(
130+ popupID port.WebViewID,
131+ parentPaneID entity.PaneID,
132+ targetURI string,
133+) (entity.PaneID, *entity.Pane) {
134+ var paneID entity.PaneID
135+ if pm != nil && pm.generatePaneID != nil {
136+ paneID = entity.PaneID(pm.generatePaneID())
137+ } else {
138+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
139+ }
140+
141+ popupPane := entity.NewPane(paneID)
142+ popupPane.WindowType = entity.WindowPopup
143+ popupPane.IsRelated = true
144+ popupPane.ParentPaneID = &parentPaneID
145+ popupPane.URI = targetURI
146+
147+ return paneID, popupPane
148+}
149+
150+func (pm *popupManager) relatedPopupSupportDisabled() bool {
151+ if pm == nil {
152+ return false
153+ }
154+ pm.mu.RLock()
155+ defer pm.mu.RUnlock()
156+ return pm.relatedPopupSupportDetected && pm.relatedPopupUnsupported
157+}
158+
159+func (pm *popupManager) markRelatedPopupUnsupported() {
160+ if pm == nil {
161+ return
162+ }
163+ pm.mu.Lock()
164+ defer pm.mu.Unlock()
165+ pm.relatedPopupSupportDetected = true
166+ pm.relatedPopupUnsupported = true
167+}
168+
169+func (pm *popupManager) markRelatedPopupSupported() {
170+ if pm == nil {
171+ return
172+ }
173+ pm.mu.Lock()
174+ defer pm.mu.Unlock()
175+ pm.relatedPopupSupportDetected = true
176+ pm.relatedPopupUnsupported = false
177+}
178+
179+func (pm *popupManager) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
180+ if pm == nil || !isReusableNamedPopupFrame(frameName) {
181+ return nil, false
182+ }
183+
184+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
185+
186+ pm.mu.RLock()
187+ state, ok := pm.namedPopups[key]
188+ pm.mu.RUnlock()
189+ if !ok || state == nil || state.WebView == nil {
190+ return nil, false
191+ }
192+
193+ if state.WebView.IsDestroyed() {
194+ pm.mu.Lock()
195+ if current, ok := pm.namedPopups[key]; ok && current == state {
196+ delete(pm.namedPopups, key)
197+ }
198+ pm.mu.Unlock()
199+ return nil, false
200+ }
201+
202+ return state, true
203+}
204+
205+func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, wv port.WebView) {
206+ if pm == nil || !isReusableNamedPopupFrame(frameName) || wv == nil {
207+ return
208+ }
209+
210+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
211+ pm.mu.Lock()
212+ pm.namedPopups[key] = &namedPopupState{WebView: wv}
213+ pm.mu.Unlock()
214+}
215+
216+func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
217+ if pm == nil {
218+ return
219+ }
220+ pm.mu.Lock()
221+ if pending, ok := pm.pendingPopups[popupID]; ok && pending != nil {
222+ pending.TargetURI = targetURI
223+ }
224+ pm.mu.Unlock()
225+}
226+
227+func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
228+ if pm == nil {
229+ return
230+ }
231+ pm.mu.Lock()
232+ for key, state := range pm.namedPopups {
233+ if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
234+ delete(pm.namedPopups, key)
235+ }
236+ }
237+ pm.mu.Unlock()
238+}
239+
240+func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
241+ if pm == nil || pending == nil {
242+ return
243+ }
244+ pm.mu.Lock()
245+ pm.pendingPopups[popupID] = pending
246+ pm.mu.Unlock()
247+}
248+
249+func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, bool) {
250+ if pm == nil {
251+ return nil, false
252+ }
253+ pm.mu.Lock()
254+ defer pm.mu.Unlock()
255+ pending, ok := pm.pendingPopups[popupID]
256+ if ok {
257+ delete(pm.pendingPopups, popupID)
258+ }
259+ return pending, ok
260+}
261+
262+func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
263+ if pm == nil {
264+ return
265+ }
266+ pm.mu.Lock()
267+ pm.popupOAuth[popupID] = &popupOAuthState{
268+ ParentPaneID: parentPaneID,
269+ ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
270+ }
271+ pm.mu.Unlock()
272+}
273+
274+func (pm *popupManager) capturePopupOAuthState(popupID port.WebViewID, uri string) {
275+ if pm == nil {
276+ return
277+ }
278+ pm.mu.Lock()
279+ defer pm.mu.Unlock()
280+ state, ok := pm.popupOAuth[popupID]
281+ if !ok {
282+ return
283+ }
284+ state.Seen = true
285+ state.CallbackURI = uri
286+ state.Success = IsOAuthSuccess(uri)
287+ state.Error = IsOAuthError(uri)
288+}
289+
290+func (pm *popupManager) capturePopupOAuthMessage(popupID port.WebViewID) {
291+ if pm == nil {
292+ return
293+ }
294+ pm.mu.Lock()
295+ defer pm.mu.Unlock()
296+ state, ok := pm.popupOAuth[popupID]
297+ if !ok {
298+ return
299+ }
300+ state.Seen = true
301+ if state.CallbackURI == "" {
302+ state.CallbackURI = "postmessage://oauth-complete"
303+ }
304+ if !state.Error {
305+ state.Success = true
306+ }
307+}
308+
309+func (pm *popupManager) takePopupOAuthState(popupID port.WebViewID) (*popupOAuthState, bool) {
310+ if pm == nil {
311+ return nil, false
312+ }
313+ pm.mu.Lock()
314+ defer pm.mu.Unlock()
315+ state, ok := pm.popupOAuth[popupID]
316+ if ok {
317+ delete(pm.popupOAuth, popupID)
318+ }
319+ return state, ok
320+}
321+
322+func (pm *popupManager) schedulePopupRefresh(parentPaneID entity.PaneID, debounce time.Duration, fn func()) {
323+ if pm == nil || fn == nil {
324+ return
325+ }
326+
327+ var timer *time.Timer
328+ pm.mu.Lock()
329+ if existing := pm.popupRefresh[parentPaneID]; existing != nil {
330+ existing.Stop()
331+ }
332+ timer = time.AfterFunc(debounce, func() {
333+ pm.mu.Lock()
334+ if pm.popupRefresh[parentPaneID] != timer {
335+ pm.mu.Unlock()
336+ return
337+ }
338+ delete(pm.popupRefresh, parentPaneID)
339+ pm.mu.Unlock()
340+ fn()
341+ })
342+ pm.popupRefresh[parentPaneID] = timer
343+ pm.mu.Unlock()
344+}
345+
346+func (pm *popupManager) createPopupWebView(
347+ ctx context.Context,
348+ parentID port.WebViewID,
349+ targetURI string,
350+ noJavaScriptAccess bool,
351+) (port.WebView, bool, error) {
352+ if pm == nil || pm.factory == nil {
353+ return nil, false, fmt.Errorf("no webview factory configured")
354+ }
355+
356+ log := logging.FromContext(ctx)
357+ var relatedErr error
358+
359+ if !noJavaScriptAccess && !pm.relatedPopupSupportDisabled() {
360+ popupWV, err := pm.factory.CreateRelated(ctx, parentID)
361+ if err == nil && popupWV != nil {
362+ pm.markRelatedPopupSupported()
363+ return popupWV, false, nil
364+ }
365+ if err == nil {
366+ relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
367+ log.Warn().
368+ Uint64("parent_webview_id", uint64(parentID)).
369+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
370+ Msg("related popup webview factory returned nil popup, falling back to regular webview")
371+ } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
372+ relatedErr = err
373+ pm.markRelatedPopupUnsupported()
374+ log.Debug().
375+ Err(err).
376+ Uint64("parent_webview_id", uint64(parentID)).
377+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
378+ Msg("related popup webview unavailable, falling back to regular webview")
379+ } else {
380+ relatedErr = err
381+ log.Warn().
382+ Err(err).
383+ Uint64("parent_webview_id", uint64(parentID)).
384+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
385+ Msg("related popup webview creation failed, falling back to regular webview")
386+ }
387+ } else if noJavaScriptAccess {
388+ log.Debug().
389+ Uint64("parent_webview_id", uint64(parentID)).
390+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
391+ Msg("popup requested no JavaScript opener access, creating regular webview")
392+ } else {
393+ log.Debug().
394+ Uint64("parent_webview_id", uint64(parentID)).
395+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
396+ Msg("related popup webviews known unsupported, creating regular webview")
397+ }
398+
399+ popupWV, fallbackErr := pm.factory.Create(ctx)
400+ if fallbackErr != nil {
401+ if relatedErr != nil {
402+ return nil, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
403+ }
404+ return nil, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
405+ }
406+ if popupWV == nil {
407+ if relatedErr != nil {
408+ return nil, false, fmt.Errorf("create popup webview: related failed: %w; fallback returned nil", relatedErr)
409+ }
410+ return nil, false, fmt.Errorf("create popup webview: fallback returned nil")
411+ }
412+
413+ return popupWV, true, nil
414+}
415+
416+func (pm *popupManager) reuseNamedPopup(
417+ ctx context.Context,
418+ parentPaneID entity.PaneID,
419+ frameName string,
420+ targetURI string,
421+) (port.WebView, bool) {
422+ log := logging.FromContext(ctx)
423+
424+ if existing, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName); ok {
425+ pm.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
426+ if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
427+ log.Warn().Err(err).
428+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
429+ Msg("failed to load target URI in reused popup")
430+ }
431+ log.Info().
432+ Str("parent_pane", string(parentPaneID)).
433+ Str("frame_name", frameName).
434+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
435+ Msg("reused named popup")
436+ return existing.WebView, true
437+ }
438+
439+ return nil, false
440+}
441+
442+func (pm *popupManager) handlePopupCreate(
443+ ctx context.Context,
444+ hooks popupCoordinatorHooks,
445+ parentPaneID entity.PaneID,
446+ parentWV port.WebView,
447+ req port.PopupRequest,
448+) port.WebView {
449+ log := logging.FromContext(ctx)
450+
451+ log.Debug().
452+ Str("parent_pane", string(parentPaneID)).
453+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
454+ Str("frame_name", req.FrameName).
455+ Bool("user_gesture", req.IsUserGesture).
456+ Bool("no_javascript_access", req.NoJavaScriptAccess).
457+ Msg("popup create request")
458+
459+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
460+ log.Debug().Msg("popups disabled by config, blocking")
461+ return nil
462+ }
463+ if pm.factory == nil {
464+ log.Warn().Msg("no webview factory, cannot create popup")
465+ return nil
466+ }
467+
468+ parentID := parentWV.ID()
469+ parentURIAtOpen := ""
470+ if pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
471+ parentURIAtOpen = parentWV.URI()
472+ if parentURIAtOpen == "" && hooks.getWebView != nil {
473+ if parent := hooks.getWebView(parentPaneID); parent != nil {
474+ parentURIAtOpen = parent.URI()
475+ }
476+ }
477+ }
478+ if !req.NoJavaScriptAccess {
479+ if reused, ok := pm.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
480+ return reused
481+ }
482+ }
483+
484+ popupWV, usedRegularFallback, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
485+ if err != nil {
486+ log.Error().Err(err).Msg("failed to create webview for popup")
487+ return nil
488+ }
489+ if usedRegularFallback {
490+ if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
491+ openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
492+ }
493+ }
494+
495+ popupType := DetectPopupType(req.FrameName)
496+ popupID := popupWV.ID()
497+ behavior := GetBehavior(popupType, pm.popupConfig)
498+ placement := "right"
499+ if pm.popupConfig != nil {
500+ placement = pm.popupConfig.Placement
501+ }
502+ paneID, popupPane := pm.createPopupPane(popupID, parentPaneID, req.TargetURI)
503+
504+ return pm.finishPopupCreate(ctx, hooks, popupCreateContext{
505+ ParentPaneID: parentPaneID,
506+ ParentWebViewID: parentID,
507+ ParentURIAtOpen: parentURIAtOpen,
508+ PopupID: popupID,
509+ PopupWebView: popupWV,
510+ PopupPane: popupPane,
511+ PopupPaneID: paneID,
512+ PopupType: popupType,
513+ Behavior: behavior,
514+ Placement: placement,
515+ Request: req,
516+ })
517+}
518+
519+func (pm *popupManager) finishPopupCreate(
520+ ctx context.Context,
521+ hooks popupCoordinatorHooks,
522+ create popupCreateContext,
523+) port.WebView {
524+ log := logging.FromContext(ctx)
525+ hasConfig := pm.popupConfig != nil
526+ oauthEnabled := hasConfig && pm.popupConfig.OAuthAutoClose
527+ isOAuth := IsOAuthURL(create.Request.TargetURI)
528+
529+ log.Debug().
530+ Bool("has_config", hasConfig).
531+ Bool("oauth_enabled", oauthEnabled).
532+ Bool("is_oauth", isOAuth).
533+ Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
534+ Msg("popup OAuth check")
535+
536+ if hooks.setupWebViewCallbacks != nil {
537+ hooks.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
538+ }
539+ if hooks.registerPopupWebView != nil {
540+ hooks.registerPopupWebView(create.PopupPaneID, create.PopupWebView)
541+ }
542+
543+ inserted := false
544+ defer func() {
545+ if inserted {
546+ return
547+ }
548+ if hooks.getWebView != nil && hooks.deleteWebView != nil {
549+ if current := hooks.getWebView(create.PopupPaneID); current == create.PopupWebView {
550+ hooks.deleteWebView(create.PopupPaneID)
551+ }
552+ }
553+ }()
554+
555+ if pm.onInsertPopup != nil {
556+ popupInput := InsertPopupInput{
557+ ParentPaneID: create.ParentPaneID,
558+ PopupPane: create.PopupPane,
559+ WebView: create.PopupWebView,
560+ Behavior: create.Behavior,
561+ Placement: create.Placement,
562+ PopupType: create.PopupType,
563+ TargetURI: create.Request.TargetURI,
564+ }
565+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
566+ log.Error().Err(err).Msg("failed to insert popup into workspace")
567+ create.PopupWebView.Destroy()
568+ return nil
569+ }
570+ }
571+ inserted = true
572+
573+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
574+ lifecycle.PrimePopupNavigation(create.Request.TargetURI)
575+ }
576+ if !create.Request.NoJavaScriptAccess {
577+ pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
578+ }
579+ if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
580+ if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
581+ closeCapable.AddCloseCallback(func() {
582+ pm.handlePopupClose(context.Background(), hooks, create.PopupID)
583+ })
584+ }
585+ }
586+
587+ if hasConfig && oauthEnabled && isOAuth {
588+ create.PopupPane.AutoClose = true
589+ pm.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
590+ if hooks.setupOAuthAutoClose != nil {
591+ hooks.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
592+ }
593+ log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
594+ }
595+
596+ pending := &PendingPopup{
597+ PaneID: create.PopupPaneID,
598+ WebView: create.PopupWebView,
599+ ParentPaneID: create.ParentPaneID,
600+ ParentWebViewID: create.ParentWebViewID,
601+ TargetURI: create.Request.TargetURI,
602+ FrameName: create.Request.FrameName,
603+ IsUserGesture: create.Request.IsUserGesture,
604+ PopupType: create.PopupType,
605+ CreatedAt: time.Now(),
606+ }
607+ pm.storePendingPopup(create.PopupID, pending)
608+
609+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
610+ lifecycle.SetOnReadyToShow(func() {
611+ pm.handlePopupReadyToShow(context.Background(), create.PopupID)
612+ })
613+ lifecycle.SetOnClose(func() {
614+ pm.handlePopupClose(context.Background(), hooks, create.PopupID)
615+ })
616+ log.Info().
617+ Uint64("popup_id", uint64(create.PopupID)).
618+ Str("pane_id", string(create.PopupPaneID)).
619+ Str("popup_type", create.PopupType.String()).
620+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
621+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
622+ } else {
623+ log.Info().
624+ Uint64("popup_id", uint64(create.PopupID)).
625+ Str("pane_id", string(create.PopupPaneID)).
626+ Str("popup_type", create.PopupType.String()).
627+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
628+ Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
629+ pm.handlePopupReadyToShow(context.Background(), create.PopupID)
630+ }
631+
632+ return create.PopupWebView
633+}
634+
635+func (pm *popupManager) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
636+ log := logging.FromContext(ctx)
637+ pending, ok := pm.takePendingPopup(popupID)
638+ if !ok || pending == nil {
639+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
640+ return
641+ }
642+
643+ log.Debug().
644+ Uint64("popup_id", uint64(popupID)).
645+ Str("popup_type", pending.PopupType.String()).
646+ Msg("popup ready to show - making visible")
647+
648+ if pending.WebView != nil {
649+ lifecycle, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
650+ if preloadsNavigation {
651+ lifecycle.Show()
652+ }
653+ if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
654+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
655+ log.Warn().Err(err).
656+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
657+ Msg("failed to load target URI in popup")
658+ }
659+ }
660+ }
661+
662+ log.Info().
663+ Uint64("popup_id", uint64(popupID)).
664+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
665+ Msg("popup now visible")
666+}
667+
668+func (pm *popupManager) logPopupCloseSignal(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
669+ fields := logging.FromContext(ctx).Debug().Uint64("popup_id", uint64(popupID))
670+ if hooks.findPaneByWebViewID != nil && hooks.getWebView != nil {
671+ if paneID, ok := hooks.findPaneByWebViewID(popupID); ok && paneID != "" {
672+ fields = fields.Str("pane_id", string(paneID))
673+ if wv := hooks.getWebView(paneID); wv != nil {
674+ fields = fields.
675+ Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).
676+ Bool("is_loading", wv.IsLoading()).
677+ Bool("synthetic_opener_active", popupUsesSyntheticOpenerSignals(wv))
678+ }
679+ }
680+ }
681+ fields.Msg("popup close signal received")
682+}
683+
684+func (pm *popupManager) findPopupPaneForClose(hooks popupCoordinatorHooks, popupID port.WebViewID) (entity.PaneID, bool) {
685+ if hooks.findPaneByWebViewID == nil {
686+ return "", false
687+ }
688+ paneID, ok := hooks.findPaneByWebViewID(popupID)
689+ return paneID, ok && paneID != ""
690+}
691+
692+func (pm *popupManager) releasePopupWebView(ctx context.Context, hooks popupCoordinatorHooks, paneID entity.PaneID) {
693+ if paneID == "" || hooks.getWebView == nil || hooks.releaseWebView == nil {
694+ return
695+ }
696+ if hooks.getWebView(paneID) != nil {
697+ hooks.releaseWebView(ctx, paneID)
698+ }
699+}
700+
701+func (pm *popupManager) cleanupPopupClose(
702+ ctx context.Context,
703+ hooks popupCoordinatorHooks,
704+ popupID port.WebViewID,
705+ paneID entity.PaneID,
706+ closeErrMsg string,
707+) {
708+ log := logging.FromContext(ctx)
709+ if hooks.handlePopupOAuthClose != nil {
710+ hooks.handlePopupOAuthClose(ctx, popupID)
711+ }
712+ if paneID != "" && pm.onClosePane != nil {
713+ if err := pm.onClosePane(ctx, paneID); err != nil {
714+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg(closeErrMsg)
715+ }
716+ }
717+ pm.clearReusableNamedPopupByWebViewID(popupID)
718+ pm.releasePopupWebView(ctx, hooks, paneID)
719+}
720+
721+func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
722+ log := logging.FromContext(ctx)
723+ pm.logPopupCloseSignal(ctx, hooks, popupID)
724+
725+ pending, wasPending := pm.takePendingPopup(popupID)
726+ if wasPending && pending != nil {
727+ pm.cleanupPopupClose(ctx, hooks, popupID, pending.PaneID, "failed to close pending popup pane")
728+ log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
729+ return
730+ }
731+
732+ paneID, ok := pm.findPopupPaneForClose(hooks, popupID)
733+ if !ok {
734+ pm.cleanupPopupClose(ctx, hooks, popupID, "", "")
735+ log.Warn().Msg("popup close: could not find pane for webview")
736+ return
737+ }
738+
739+ pm.cleanupPopupClose(ctx, hooks, popupID, paneID, "failed to close popup pane")
740+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
741+}
742+
743+func (pm *popupManager) handleLinkMiddleClick(
744+ ctx context.Context,
745+ hooks popupCoordinatorHooks,
746+ parentPaneID entity.PaneID,
747+ uri string,
748+) bool {
749+ log := logging.FromContext(ctx)
750+
751+ log.Info().
752+ Str("parent_pane", string(parentPaneID)).
753+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
754+ Msg("middle-click/ctrl+click on link")
755+
756+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
757+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
758+ return false
759+ }
760+ if pm.factory == nil {
761+ log.Warn().Msg("no webview factory, cannot handle middle-click")
762+ return false
763+ }
764+
765+ newWV, err := pm.factory.Create(ctx)
766+ if err != nil {
767+ log.Error().Err(err).Msg("failed to create webview for middle-click")
768+ return false
769+ }
770+
771+ var paneID entity.PaneID
772+ if pm.generatePaneID != nil {
773+ paneID = entity.PaneID(pm.generatePaneID())
774+ } else {
775+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
776+ }
777+
778+ newPane := entity.NewPane(paneID)
779+ newPane.WindowType = entity.WindowPopup
780+ newPane.URI = uri
781+
782+ if hooks.setWebView != nil {
783+ hooks.setWebView(paneID, newWV)
784+ }
785+ if hooks.setupWebViewCallbacks != nil {
786+ hooks.setupWebViewCallbacks(ctx, paneID, newWV)
787+ }
788+
789+ behavior := entity.PopupBehaviorStacked
790+ if pm.popupConfig != nil && pm.popupConfig.BlankTargetBehavior != "" {
791+ behavior = entity.PopupBehavior(pm.popupConfig.BlankTargetBehavior)
792+ }
793+ placement := "right"
794+ if pm.popupConfig != nil {
795+ placement = pm.popupConfig.Placement
796+ }
797+
798+ if pm.onInsertPopup != nil {
799+ popupInput := InsertPopupInput{
800+ ParentPaneID: parentPaneID,
801+ PopupPane: newPane,
802+ WebView: newWV,
803+ Behavior: behavior,
804+ Placement: placement,
805+ PopupType: PopupTypeTab,
806+ TargetURI: uri,
807+ }
808+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
809+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
810+ if hooks.deleteWebView != nil {
811+ hooks.deleteWebView(paneID)
812+ }
813+ newWV.Destroy()
814+ return false
815+ }
816+ }
817+
818+ if err := newWV.LoadURI(ctx, uri); err != nil {
819+ log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
820+ }
821+
822+ log.Info().
823+ Str("pane_id", string(paneID)).
824+ Str("behavior", string(behavior)).
825+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
826+ Msg("middle-click link opened in new pane")
827+
828+ return true
829+}
M internal/ui/coordinator/content/popup_test.go +400 -54
@@ -2,6 +2,7 @@ package content
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "testing"
78
@@ -12,6 +13,7 @@ import (
1213 "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/application/port/mocks"
1415 "github.com/bnema/dumber/internal/domain/entity"
16+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
1517 )
1618
1719 // ---------------------------------------------------------------------------
@@ -125,6 +127,321 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
125127 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
126128 }
127129
130+func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
131+ t.Helper()
132+
133+ ctx := context.Background()
134+ parentPaneID := entity.PaneID("parent-pane")
135+ parentWV := mocks.NewMockWebView(t)
136+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
137+
138+ popupWV := mocks.NewMockWebView(t)
139+ popupWV.EXPECT().ID().Return(popupID).Once()
140+
141+ factory := mocks.NewMockWebViewFactory(t)
142+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
143+
144+ c := &Coordinator{
145+ webViews: make(map[entity.PaneID]port.WebView),
146+ popups: newPopupManager(),
147+ }
148+ c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
149+
150+ return ctx, parentPaneID, parentWV, popupWV, c
151+}
152+
153+func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
154+ ctx := context.Background()
155+ parentPaneID := entity.PaneID("parent-pane")
156+ parentWV := mocks.NewMockWebView(t)
157+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
158+
159+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
160+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()
161+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
162+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163+
164+ factory := mocks.NewMockWebViewFactory(t)
165+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
166+
167+ c := &Coordinator{
168+ webViews: make(map[entity.PaneID]port.WebView),
169+ popups: newPopupManager(),
170+ }
171+ c.SetPopupConfig(factory, nil, nil)
172+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
173+ return nil
174+ })
175+
176+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
177+ TargetURI: "https://example.com/popup",
178+ FrameName: "auth-popup",
179+ IsUserGesture: true,
180+ NoJavaScriptAccess: true,
181+ })
182+
183+ require.Same(t, popupWV, created)
184+ require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
185+}
186+
187+func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
188+ ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
189+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
190+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
191+ popupWV.EXPECT().IsLoading().Return(false).Once()
192+ popupWV.EXPECT().URI().Return("").Once()
193+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once()
194+
195+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
196+ require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID)
197+ require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
198+ return nil
199+ })
200+
201+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
202+ TargetURI: "https://example.com/popup",
203+ FrameName: "auth-popup",
204+ IsUserGesture: true,
205+ NoJavaScriptAccess: true,
206+ })
207+
208+ require.Same(t, popupWV, created)
209+}
210+
211+func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
212+ ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))
213+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
214+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
215+ popupWV.EXPECT().Destroy().Once()
216+
217+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
218+ require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
219+ return assert.AnError
220+ })
221+
222+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
223+ TargetURI: "https://example.com/popup",
224+ FrameName: "auth-popup",
225+ IsUserGesture: true,
226+ NoJavaScriptAccess: true,
227+ })
228+
229+ require.Nil(t, created)
230+ require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
231+}
232+
233+func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
234+ ctx := context.Background()
235+ parentPaneID := entity.PaneID("parent-pane")
236+ parentWV := mocks.NewMockWebView(t)
237+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
238+
239+ popupWV := mocks.NewMockWebView(t)
240+ popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once()
241+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
242+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
243+ popupWV.EXPECT().IsLoading().Return(false).Once()
244+ popupWV.EXPECT().URI().Return("").Once()
245+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
246+
247+ factory := mocks.NewMockWebViewFactory(t)
248+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
249+
250+ c := &Coordinator{
251+ webViews: make(map[entity.PaneID]port.WebView),
252+ popups: newPopupManager(),
253+ }
254+ c.SetPopupConfig(factory, nil, nil)
255+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
256+ assert.Equal(t, "https://example.com/noopener", input.TargetURI)
257+ return nil
258+ })
259+
260+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
261+ TargetURI: "https://example.com/noopener",
262+ FrameName: "auth-popup",
263+ IsUserGesture: true,
264+ NoJavaScriptAccess: true,
265+ })
266+
267+ require.Same(t, popupWV, created)
268+}
269+
270+func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *testing.T) {
271+ ctx := context.Background()
272+ parentPaneID := entity.PaneID("parent-pane")
273+ parentWV := mocks.NewMockWebView(t)
274+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
275+
276+ firstPopupWV := mocks.NewMockWebView(t)
277+ firstPopupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
278+ firstPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
279+ firstPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
280+ firstPopupWV.EXPECT().IsLoading().Return(false).Once()
281+ firstPopupWV.EXPECT().URI().Return("").Once()
282+ firstPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Once()
283+
284+ secondPopupWV := mocks.NewMockWebView(t)
285+ secondPopupWV.EXPECT().ID().Return(port.WebViewID(203)).Once()
286+ secondPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
287+ secondPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
288+ secondPopupWV.EXPECT().IsLoading().Return(false).Once()
289+ secondPopupWV.EXPECT().URI().Return("").Once()
290+ secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
291+
292+ factory := mocks.NewMockWebViewFactory(t)
293+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
294+ factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
295+ factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
296+
297+ c := &Coordinator{
298+ webViews: make(map[entity.PaneID]port.WebView),
299+ popups: newPopupManager(),
300+ }
301+ c.SetPopupConfig(factory, nil, nil)
302+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
303+ return nil
304+ })
305+
306+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
307+ TargetURI: "https://example.com/first",
308+ FrameName: "_blank",
309+ IsUserGesture: true,
310+ })
311+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
312+ TargetURI: "https://example.com/second",
313+ FrameName: "_blank",
314+ IsUserGesture: true,
315+ })
316+
317+ require.Same(t, firstPopupWV, first)
318+ require.Same(t, secondPopupWV, second)
319+}
320+
321+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupported(t *testing.T) {
322+ ctx := context.Background()
323+ parentPaneID := entity.PaneID("parent-pane")
324+ parentWV := mocks.NewMockWebView(t)
325+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
326+
327+ popupWV := mocks.NewMockWebView(t)
328+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
329+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
330+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
331+ popupWV.EXPECT().IsLoading().Return(false).Once()
332+ popupWV.EXPECT().URI().Return("").Once()
333+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
334+
335+ factory := mocks.NewMockWebViewFactory(t)
336+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
337+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
338+
339+ insertCalls := 0
340+ c := &Coordinator{
341+ webViews: make(map[entity.PaneID]port.WebView),
342+ popups: newPopupManager(),
343+ }
344+ c.SetPopupConfig(factory, nil, nil)
345+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
346+ insertCalls++
347+ assert.Equal(t, parentPaneID, input.ParentPaneID)
348+ assert.Equal(t, popupWV, input.WebView)
349+ assert.Equal(t, "https://example.com/edit", input.TargetURI)
350+ return nil
351+ })
352+
353+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
354+ TargetURI: "https://example.com/edit",
355+ FrameName: "_blank",
356+ IsUserGesture: true,
357+ })
358+
359+ require.Same(t, popupWV, created)
360+ assert.Equal(t, 1, insertCalls)
361+}
362+
363+func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
364+ ctx := context.Background()
365+ parentPaneID := entity.PaneID("parent-pane")
366+ parentWV := mocks.NewMockWebView(t)
367+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
368+
369+ popupWV := mocks.NewMockWebView(t)
370+ popupWV.EXPECT().ID().Return(port.WebViewID(204)).Once()
371+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
372+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
373+ popupWV.EXPECT().IsLoading().Return(false).Once()
374+ popupWV.EXPECT().URI().Return("").Once()
375+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/edit").Return(nil).Once()
376+
377+ factory := mocks.NewMockWebViewFactory(t)
378+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
379+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
380+
381+ insertCalls := 0
382+ c := &Coordinator{
383+ webViews: make(map[entity.PaneID]port.WebView),
384+ popups: newPopupManager(),
385+ }
386+ c.SetPopupConfig(factory, nil, nil)
387+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
388+ insertCalls++
389+ assert.Equal(t, popupWV, input.WebView)
390+ return nil
391+ })
392+
393+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
394+ TargetURI: "https://example.com/edit",
395+ FrameName: "_blank",
396+ IsUserGesture: true,
397+ })
398+
399+ require.Same(t, popupWV, created)
400+ assert.Equal(t, 1, insertCalls)
401+}
402+
403+func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported(t *testing.T) {
404+ ctx := context.Background()
405+ parentPaneID := entity.PaneID("parent-pane")
406+ parentWV := mocks.NewMockWebView(t)
407+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
408+
409+ popupWV := &popupOpenerBridgeWebViewStub{MockWebView: mocks.NewMockWebView(t)}
410+ popupWV.EXPECT().ID().Return(port.WebViewID(303)).Once()
411+ popupWV.EXPECT().Generation().Return(uint64(1)).Once()
412+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
413+ popupWV.EXPECT().IsLoading().Return(false).Once()
414+ popupWV.EXPECT().URI().Return("").Once()
415+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup?redirect=%2Fcallback").Return(nil).Once()
416+
417+ factory := mocks.NewMockWebViewFactory(t)
418+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
419+ factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
420+
421+ insertCalls := 0
422+ c := &Coordinator{
423+ webViews: make(map[entity.PaneID]port.WebView),
424+ popups: newPopupManager(),
425+ }
426+ c.SetPopupConfig(factory, nil, nil)
427+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
428+ insertCalls++
429+ assert.Equal(t, "https://example.com/popup?redirect=%2Fcallback", input.TargetURI)
430+ return nil
431+ })
432+
433+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
434+ TargetURI: "https://example.com/popup?redirect=%2Fcallback",
435+ FrameName: "auth-popup",
436+ IsUserGesture: true,
437+ })
438+
439+ require.Same(t, popupWV, created)
440+ assert.Equal(t, 1, insertCalls)
441+ assert.Same(t, parentWV, popupWV.parent)
442+ assert.False(t, popupWV.noJavaScriptAccess)
443+}
444+
128445 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
129446 ctx := context.Background()
130447 parentPaneID := entity.PaneID("parent-pane")
@@ -153,9 +470,8 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
153470
154471 insertCalls := 0
155472 c := &Coordinator{
156- webViews: make(map[entity.PaneID]port.WebView),
157- pendingPopups: make(map[port.WebViewID]*PendingPopup),
158- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
473+ webViews: make(map[entity.PaneID]port.WebView),
474+ popups: newPopupManager(),
159475 }
160476 c.SetPopupConfig(factory, nil, nil)
161477 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
@@ -189,22 +505,23 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
189505 t.Parallel()
190506
191507 c := &Coordinator{
192- webViews: make(map[entity.PaneID]port.WebView),
193- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
508+ webViews: make(map[entity.PaneID]port.WebView),
509+ popups: newPopupManager(),
194510 }
195511
196512 popupID := port.WebViewID(1)
197513 parentPaneID := entity.PaneID("parent-pane")
198514
199- c.trackOAuthPopup(popupID, parentPaneID)
515+ c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
200516
201- c.popupMu.RLock()
202- state, ok := c.popupOAuth[popupID]
203- c.popupMu.RUnlock()
517+ c.popups.mu.RLock()
518+ state, ok := c.popups.popupOAuth[popupID]
519+ c.popups.mu.RUnlock()
204520
205521 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
206522 require.NotNil(t, state)
207523 assert.Equal(t, parentPaneID, state.ParentPaneID)
524+ assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
208525 assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
209526 }
210527
@@ -212,17 +529,17 @@ func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
212529 t.Parallel()
213530
214531 c := &Coordinator{
215- webViews: make(map[entity.PaneID]port.WebView),
216- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
532+ webViews: make(map[entity.PaneID]port.WebView),
533+ popups: newPopupManager(),
217534 }
218535
219536 popupID := port.WebViewID(2)
220- c.trackOAuthPopup(popupID, entity.PaneID("parent"))
537+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
221538 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
222539
223- c.popupMu.RLock()
224- state := c.popupOAuth[popupID]
225- c.popupMu.RUnlock()
540+ c.popups.mu.RLock()
541+ state := c.popups.popupOAuth[popupID]
542+ c.popups.mu.RUnlock()
226543
227544 require.NotNil(t, state)
228545 assert.True(t, state.Seen)
@@ -235,17 +552,17 @@ func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
235552 t.Parallel()
236553
237554 c := &Coordinator{
238- webViews: make(map[entity.PaneID]port.WebView),
239- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
555+ webViews: make(map[entity.PaneID]port.WebView),
556+ popups: newPopupManager(),
240557 }
241558
242559 popupID := port.WebViewID(3)
243- c.trackOAuthPopup(popupID, entity.PaneID("parent"))
560+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
244561 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
245562
246- c.popupMu.RLock()
247- state := c.popupOAuth[popupID]
248- c.popupMu.RUnlock()
563+ c.popups.mu.RLock()
564+ state := c.popups.popupOAuth[popupID]
565+ c.popups.mu.RUnlock()
249566
250567 require.NotNil(t, state)
251568 assert.True(t, state.Seen)
@@ -257,16 +574,16 @@ func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
257574 t.Parallel()
258575
259576 c := &Coordinator{
260- webViews: make(map[entity.PaneID]port.WebView),
261- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
577+ webViews: make(map[entity.PaneID]port.WebView),
578+ popups: newPopupManager(),
262579 }
263580
264581 // Should not panic when no tracking entry exists.
265582 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
266583
267- c.popupMu.RLock()
268- _, ok := c.popupOAuth[port.WebViewID(999)]
269- c.popupMu.RUnlock()
584+ c.popups.mu.RLock()
585+ _, ok := c.popups.popupOAuth[port.WebViewID(999)]
586+ c.popups.mu.RUnlock()
270587
271588 assert.False(t, ok)
272589 }
@@ -286,10 +603,10 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
286603
287604 c.SetPopupConfig(factory, cfg, genID)
288605
289- assert.Equal(t, factory, c.factory)
290- assert.Equal(t, cfg, c.popupConfig)
291- assert.NotNil(t, c.generateID)
292- assert.Equal(t, "test-id", c.generateID())
606+ assert.Equal(t, factory, c.popups.factory)
607+ assert.Equal(t, cfg, c.popups.popupConfig)
608+ assert.NotNil(t, c.popups.generatePaneID)
609+ assert.Equal(t, "test-id", c.popups.generatePaneID())
293610 }
294611
295612 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
@@ -298,10 +615,39 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
298615 c := &Coordinator{}
299616 c.SetPopupConfig(nil, nil, nil)
300617
301- assert.Nil(t, c.factory)
302- assert.Nil(t, c.popupConfig)
303- assert.Nil(t, c.generateID)
618+ assert.Nil(t, c.popups.factory)
619+ assert.Nil(t, c.popups.popupConfig)
620+ assert.Nil(t, c.popups.generatePaneID)
621+}
622+
623+type popupNavigationWebViewStub struct {
624+ *mocks.MockWebView
625+ primed []string
626+}
627+
628+func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
629+ s.primed = append(s.primed, uri)
630+}
631+
632+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
633+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
634+func (*popupNavigationWebViewStub) Show() {}
635+
636+type popupOpenerBridgeWebViewStub struct {
637+ *mocks.MockWebView
638+ parent port.WebView
639+ noJavaScriptAccess bool
640+}
641+
642+func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
643+ s.parent = parent
644+ s.noJavaScriptAccess = noJavaScriptAccess
645+}
646+
647+func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {}
648+func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) {
304649 }
650+func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
305651
306652 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
307653 type stubFactory struct{}
@@ -322,7 +668,7 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
322668 t.Parallel()
323669
324670 c := &Coordinator{
325- pendingPopups: make(map[port.WebViewID]*PendingPopup),
671+ popups: newPopupManager(),
326672 }
327673
328674 const workers = 20
@@ -334,12 +680,12 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
334680 go func(id int) {
335681 defer wg.Done()
336682 popupID := port.WebViewID(id)
337- c.popupMu.Lock()
338- c.pendingPopups[popupID] = &PendingPopup{
683+ c.popups.mu.Lock()
684+ c.popups.pendingPopups[popupID] = &PendingPopup{
339685 FrameName: "_blank",
340686 PopupType: PopupTypeTab,
341687 }
342- c.popupMu.Unlock()
688+ c.popups.mu.Unlock()
343689 }(i)
344690 }
345691
@@ -348,17 +694,17 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
348694 go func(id int) {
349695 defer wg.Done()
350696 popupID := port.WebViewID(id)
351- c.popupMu.RLock()
352- _ = c.pendingPopups[popupID]
353- c.popupMu.RUnlock()
697+ c.popups.mu.RLock()
698+ _ = c.popups.pendingPopups[popupID]
699+ c.popups.mu.RUnlock()
354700 }(i)
355701 }
356702
357703 wg.Wait()
358704
359- c.popupMu.RLock()
360- count := len(c.pendingPopups)
361- c.popupMu.RUnlock()
705+ c.popups.mu.RLock()
706+ count := len(c.popups.pendingPopups)
707+ c.popups.mu.RUnlock()
362708
363709 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
364710 }
@@ -367,12 +713,12 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
367713 t.Parallel()
368714
369715 c := &Coordinator{
370- pendingPopups: make(map[port.WebViewID]*PendingPopup),
716+ popups: newPopupManager(),
371717 }
372718
373719 // Pre-populate
374720 for i := 0; i < 10; i++ {
375- c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
721+ c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
376722 }
377723
378724 var wg sync.WaitGroup
@@ -382,9 +728,9 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
382728 for i := 0; i < 10; i++ {
383729 go func(id int) {
384730 defer wg.Done()
385- c.popupMu.Lock()
386- delete(c.pendingPopups, port.WebViewID(id))
387- c.popupMu.Unlock()
731+ c.popups.mu.Lock()
732+ delete(c.popups.pendingPopups, port.WebViewID(id))
733+ c.popups.mu.Unlock()
388734 }(i)
389735 }
390736
@@ -392,15 +738,15 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
392738 for i := 0; i < 10; i++ {
393739 go func(id int) {
394740 defer wg.Done()
395- c.popupMu.RLock()
396- _ = c.pendingPopups[port.WebViewID(id)]
397- c.popupMu.RUnlock()
741+ c.popups.mu.RLock()
742+ _ = c.popups.pendingPopups[port.WebViewID(id)]
743+ c.popups.mu.RUnlock()
398744 }(i)
399745 }
400746
401747 wg.Wait()
402748
403- c.popupMu.RLock()
404- defer c.popupMu.RUnlock()
405- assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")
749+ c.popups.mu.RLock()
750+ defer c.popups.mu.RUnlock()
751+ assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
406752 }

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