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:
42 FilterApplier: {}42 FilterApplier: {}
43 FaviconDatabase: {}43 FaviconDatabase: {}
44 IdleInhibitor: {}44 IdleInhibitor: {}
45- PopupCapable: {}45+ PopupLifecycleCapable: {}
46+ PopupOpenerCapable: {}
46 OAuthCallbackCapable: {}47 OAuthCallbackCapable: {}
47 DevToolsOpener: {}48 DevToolsOpener: {}
48 Printer: {}49 Printer: {}
M internal/application/port/mocks/mocks.go +273 -36
@@ -14273,13 +14273,13 @@ func (_c *MockPrinter_PrintPage_Call) RunAndReturn(run func()) *MockPrinter_Prin
14273 return _c14273 return _c
14274 }14274 }
14275 14275
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.
14277 // The first argument is typically a *testing.T value.14277 // The first argument is typically a *testing.T value.
14278-func NewMockPopupCapable(t interface {14278+func NewMockPopupLifecycleCapable(t interface {
14279 mock.TestingT14279 mock.TestingT
14280 Cleanup(func())14280 Cleanup(func())
14281-}) *MockPopupCapable {14281+}) *MockPopupLifecycleCapable {
14282- mock := &MockPopupCapable{}14282+ mock := &MockPopupLifecycleCapable{}
14283 mock.Mock.Test(t)14283 mock.Mock.Test(t)
14284 14284
14285 t.Cleanup(func() { mock.AssertExpectations(t) })14285 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -14287,37 +14287,77 @@ func NewMockPopupCapable(t interface {
14287 return mock14287 return mock
14288 }14288 }
14289 14289
14290-// MockPopupCapable is an autogenerated mock type for the PopupCapable type14290+// MockPopupLifecycleCapable is an autogenerated mock type for the PopupLifecycleCapable type
14291-type MockPopupCapable struct {14291+type MockPopupLifecycleCapable struct {
14292 mock.Mock14292 mock.Mock
14293 }14293 }
14294 14294
14295-type MockPopupCapable_Expecter struct {14295+type MockPopupLifecycleCapable_Expecter struct {
14296 mock *mock.Mock14296 mock *mock.Mock
14297 }14297 }
14298 14298
14299-func (_m *MockPopupCapable) EXPECT() *MockPopupCapable_Expecter {14299+func (_m *MockPopupLifecycleCapable) EXPECT() *MockPopupLifecycleCapable_Expecter {
14300- return &MockPopupCapable_Expecter{mock: &_m.Mock}14300+ return &MockPopupLifecycleCapable_Expecter{mock: &_m.Mock}
14301 }14301 }
14302 14302
14303-// SetOnClose provides a mock function for the type MockPopupCapable14303+// PrimePopupNavigation provides a mock function for the type MockPopupLifecycleCapable
14304-func (_mock *MockPopupCapable) SetOnClose(fn func()) {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()) {
14305 _mock.Called(fn)14345 _mock.Called(fn)
14306 return14346 return
14307 }14347 }
14308 14348
14309-// MockPopupCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose'14349+// MockPopupLifecycleCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose'
14310-type MockPopupCapable_SetOnClose_Call struct {14350+type MockPopupLifecycleCapable_SetOnClose_Call struct {
14311 *mock.Call14351 *mock.Call
14312 }14352 }
14313 14353
14314 // SetOnClose is a helper method to define mock.On call14354 // SetOnClose is a helper method to define mock.On call
14315 // - fn func()14355 // - fn func()
14316-func (_e *MockPopupCapable_Expecter) SetOnClose(fn interface{}) *MockPopupCapable_SetOnClose_Call {14356+func (_e *MockPopupLifecycleCapable_Expecter) SetOnClose(fn interface{}) *MockPopupLifecycleCapable_SetOnClose_Call {
14317- return &MockPopupCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)}14357+ return &MockPopupLifecycleCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)}
14318 }14358 }
14319 14359
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 {
14321 _c.Call.Run(func(args mock.Arguments) {14361 _c.Call.Run(func(args mock.Arguments) {
14322 var arg0 func()14362 var arg0 func()
14323 if args[0] != nil {14363 if args[0] != nil {
@@ -14330,34 +14370,34 @@ func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupC
14330 return _c14370 return _c
14331 }14371 }
14332 14372
14333-func (_c *MockPopupCapable_SetOnClose_Call) Return() *MockPopupCapable_SetOnClose_Call {14373+func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Return() *MockPopupLifecycleCapable_SetOnClose_Call {
14334 _c.Call.Return()14374 _c.Call.Return()
14335 return _c14375 return _c
14336 }14376 }
14337 14377
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 {
14339 _c.Run(run)14379 _c.Run(run)
14340 return _c14380 return _c
14341 }14381 }
14342 14382
14343-// SetOnReadyToShow provides a mock function for the type MockPopupCapable14383+// SetOnReadyToShow provides a mock function for the type MockPopupLifecycleCapable
14344-func (_mock *MockPopupCapable) SetOnReadyToShow(fn func()) {14384+func (_mock *MockPopupLifecycleCapable) SetOnReadyToShow(fn func()) {
14345 _mock.Called(fn)14385 _mock.Called(fn)
14346 return14386 return
14347 }14387 }
14348 14388
14349-// MockPopupCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow'14389+// MockPopupLifecycleCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow'
14350-type MockPopupCapable_SetOnReadyToShow_Call struct {14390+type MockPopupLifecycleCapable_SetOnReadyToShow_Call struct {
14351 *mock.Call14391 *mock.Call
14352 }14392 }
14353 14393
14354 // SetOnReadyToShow is a helper method to define mock.On call14394 // SetOnReadyToShow is a helper method to define mock.On call
14355 // - fn func()14395 // - fn func()
14356-func (_e *MockPopupCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupCapable_SetOnReadyToShow_Call {14396+func (_e *MockPopupLifecycleCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
14357- return &MockPopupCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)}14397+ return &MockPopupLifecycleCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)}
14358 }14398 }
14359 14399
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 {
14361 _c.Call.Run(func(args mock.Arguments) {14401 _c.Call.Run(func(args mock.Arguments) {
14362 var arg0 func()14402 var arg0 func()
14363 if args[0] != nil {14403 if args[0] != nil {
@@ -14370,49 +14410,246 @@ func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *Mock
14370 return _c14410 return _c
14371 }14411 }
14372 14412
14373-func (_c *MockPopupCapable_SetOnReadyToShow_Call) Return() *MockPopupCapable_SetOnReadyToShow_Call {14413+func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Return() *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
14374 _c.Call.Return()14414 _c.Call.Return()
14375 return _c14415 return _c
14376 }14416 }
14377 14417
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 {
14379 _c.Run(run)14419 _c.Run(run)
14380 return _c14420 return _c
14381 }14421 }
14382 14422
14383-// Show provides a mock function for the type MockPopupCapable14423+// Show provides a mock function for the type MockPopupLifecycleCapable
14384-func (_mock *MockPopupCapable) Show() {14424+func (_mock *MockPopupLifecycleCapable) Show() {
14385 _mock.Called()14425 _mock.Called()
14386 return14426 return
14387 }14427 }
14388 14428
14389-// MockPopupCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'14429+// MockPopupLifecycleCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
14390-type MockPopupCapable_Show_Call struct {14430+type MockPopupLifecycleCapable_Show_Call struct {
14391 *mock.Call14431 *mock.Call
14392 }14432 }
14393 14433
14394 // Show is a helper method to define mock.On call14434 // Show is a helper method to define mock.On call
14395-func (_e *MockPopupCapable_Expecter) Show() *MockPopupCapable_Show_Call {14435+func (_e *MockPopupLifecycleCapable_Expecter) Show() *MockPopupLifecycleCapable_Show_Call {
14396- return &MockPopupCapable_Show_Call{Call: _e.mock.On("Show")}14436+ return &MockPopupLifecycleCapable_Show_Call{Call: _e.mock.On("Show")}
14397 }14437 }
14398 14438
14399-func (_c *MockPopupCapable_Show_Call) Run(run func()) *MockPopupCapable_Show_Call {14439+func (_c *MockPopupLifecycleCapable_Show_Call) Run(run func()) *MockPopupLifecycleCapable_Show_Call {
14400 _c.Call.Run(func(args mock.Arguments) {14440 _c.Call.Run(func(args mock.Arguments) {
14401 run()14441 run()
14402 })14442 })
14403 return _c14443 return _c
14404 }14444 }
14405 14445
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 {
14407 _c.Call.Return()14514 _c.Call.Return()
14408 return _c14515 return _c
14409 }14516 }
14410 14517
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 {
14412 _c.Run(run)14519 _c.Run(run)
14413 return _c14520 return _c
14414 }14521 }
14415 14522
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+
14416 // 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.14653 // 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.
14417 // The first argument is typically a *testing.T value.14654 // The first argument is typically a *testing.T value.
14418 func NewMockOAuthCallbackCapable(t interface {14655 func NewMockOAuthCallbackCapable(t interface {
M internal/application/port/webview.go +24 -12
@@ -3,9 +3,7 @@
3 // remain independent of specific implementations (WebKit, GTK, etc.).3 // remain independent of specific implementations (WebKit, GTK, etc.).
4 package port4 package port
5 5
6-import (6+import "context"
7- "context"
8-)
9 7
10 // WebViewID uniquely identifies a WebView instance.8 // WebViewID uniquely identifies a WebView instance.
11 type WebViewID uint649 type WebViewID uint64
@@ -64,10 +62,11 @@ const (
64 62
65 // PopupRequest contains metadata about a popup window request.63 // PopupRequest contains metadata about a popup window request.
66 type PopupRequest struct {64 type PopupRequest struct {
67- TargetURI string65+ TargetURI string
68- FrameName string // e.g., "_blank", custom name, or empty66+ FrameName string // e.g., "_blank", custom name, or empty
69- IsUserGesture bool67+ IsUserGesture bool
70- ParentViewID WebViewID68+ NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access
69+ ParentViewID WebViewID
71 }70 }
72 71
73 // Texture represents a graphics texture returned by the engine.72 // Texture represents a graphics texture returned by the engine.
@@ -314,14 +313,27 @@ type Printer interface {
314 PrintPage()313 PrintPage()
315 }314 }
316 315
317-// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.316+// PopupLifecycleCapable is implemented by WebViews that support the full popup
318-// SetOnClose composes the provided function with any existing close handler so317+// pane lifecycle. SetOnClose composes the provided function with any existing
319-// that multiple callers can each register a close callback without overwriting318+// close handler so multiple callers can register close hooks without
320-// one another.319+// overwriting one another. PrimePopupNavigation lets engines preserve the popup
321-type PopupCapable interface {320+// target URI before the browser is fully created.
321+type PopupLifecycleCapable interface {
322 SetOnReadyToShow(fn func())322 SetOnReadyToShow(fn func())
323 SetOnClose(fn func())323 SetOnClose(fn func())
324 Show()324 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
325 }337 }
326 338
327 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.339 // 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) {
45 type dumberApp struct {45 type dumberApp struct {
46 engine *Engine46 engine *Engine
47 bph purecef.BrowserProcessHandler47 bph purecef.BrowserProcessHandler
48+ rph purecef.RenderProcessHandler
48 }49 }
49 50
50 // newDumberApp creates an App whose BrowserProcessHandler handles context51 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -53,15 +54,16 @@ type dumberApp struct {
53 func newDumberApp(engine *Engine) purecef.App {54 func newDumberApp(engine *Engine) purecef.App {
54 app := &dumberApp{engine: engine}55 app := &dumberApp{engine: engine}
55 app.bph = &dumberBPH{engine: engine}56 app.bph = &dumberBPH{engine: engine}
57+ app.rph = newPopupOpenerRenderProcessHandler()
56 return app58 return app
57 }59 }
58 60
59 // NewSubprocessApp returns a lightweight raw App implementation for CEF61 // NewSubprocessApp returns a lightweight raw App implementation for CEF
60-// subprocesses re-executed from the main dumber binary. Keep subprocesses free62+// subprocesses re-executed from the main dumber binary. Keep the legacy
61-// of custom render handlers for now: the renderer bridge introduced a startup63+// renderer bridge disabled, but allow the minimal popup-opener render handler
62-// regression where OSR pages stopped producing their first paint.64+// needed to install synthetic opener state before page scripts run.
63 func NewSubprocessApp() purecef.App {65 func NewSubprocessApp() purecef.App {
64- return &subprocessApp{}66+ return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}
65 }67 }
66 68
67 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.69 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -91,9 +93,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
91 }93 }
92 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }94 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
93 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }95 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 }
95 97
96-type subprocessApp struct{}98+type subprocessApp struct{ rph purecef.RenderProcessHandler }
97 99
98 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {100 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
99 configureCommandLine(commandLine)101 configureCommandLine(commandLine)
@@ -103,7 +105,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
103 }105 }
104 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }106 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
105 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }107 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 }
107 109
108 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,110 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
109 // child process launch tracking, and diagnostic logging.111 // 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(){
385 }, 1000);385 }, 1000);
386 })();`386 })();`
387 387
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-
624 // contentInjector implements port.ContentInjector for the CEF engine.388 // contentInjector implements port.ContentInjector for the CEF engine.
625 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
626 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -741,9 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
741 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)505 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
742 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)506 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
743 507
744- // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR508+ // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
745- // mode while the native renderer bridge remains disabled.509+ // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
746- bridgeNonce := wv.rotateBridgeNonce()510+ bridgeNonce := wv.ensureBridgeNonce()
747 if bridgeNonce == "" {511 if bridgeNonce == "" {
748 ctx := context.Background()512 ctx := context.Background()
749 if ci != nil && ci.engine != nil {513 if ci != nil && ci.engine != nil {
@@ -751,7 +515,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
751 }515 }
752 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")516 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753 } else {517 } else {
754- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))518+ wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce))
755 }519 }
756 520
757 // Video playback diagnostic — logs video element state changes.521 // Video playback diagnostic — logs video element state changes.
M internal/infrastructure/cef/content_injector_test.go +40 -6
@@ -1,17 +1,18 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
7 )8 )
8 9
9-func testClipboardSelectionFetchBridgeJS() string {10+func testTrustedPageFetchBridgeJS() string {
10- return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")11+ return buildTrustedPageFetchBridgeJS("test-bridge-nonce")
11 }12 }
12 13
13 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {14 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
14- script := testClipboardSelectionFetchBridgeJS()15+ script := testTrustedPageFetchBridgeJS()
15 16
16 require.Contains(t, script, "document.activeElement")17 require.Contains(t, script, "document.activeElement")
17 require.Contains(t, script, "selectionStart")18 require.Contains(t, script, "selectionStart")
@@ -21,7 +22,7 @@ func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *t
21 }22 }
22 23
23 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {24 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
24- script := testClipboardSelectionFetchBridgeJS()25+ script := testTrustedPageFetchBridgeJS()
25 26
26 require.Contains(t, script, "dumb:///api/focus-sync")27 require.Contains(t, script, "dumb:///api/focus-sync")
27 require.Contains(t, script, "X-Dumber-Bridge-Action")28 require.Contains(t, script, "X-Dumber-Bridge-Action")
@@ -36,7 +37,7 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
36 }37 }
37 38
38 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {39 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
39- script := testClipboardSelectionFetchBridgeJS()40+ script := testTrustedPageFetchBridgeJS()
40 41
41 require.Contains(t, script, "dumb:///api/clipboard-set")42 require.Contains(t, script, "dumb:///api/clipboard-set")
42 require.Contains(t, script, "X-Dumber-Body")43 require.Contains(t, script, "X-Dumber-Body")
@@ -47,7 +48,7 @@ func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testin
47 }48 }
48 49
49 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {50 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
50- script := testClipboardSelectionFetchBridgeJS()51+ script := testTrustedPageFetchBridgeJS()
51 52
52 require.Contains(t, script, "navigator && navigator.clipboard")53 require.Contains(t, script, "navigator && navigator.clipboard")
53 require.Contains(t, script, "clipboardProto.writeText")54 require.Contains(t, script, "clipboardProto.writeText")
@@ -61,3 +62,36 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
61 require.NotContains(t, script, "setBridgeNonce")62 require.NotContains(t, script, "setBridgeNonce")
62 require.Contains(t, script, "test-bridge-nonce")63 require.Contains(t, script, "test-bridge-nonce")
63 }64 }
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 {
49 alreadyRunningAppRelaunchHandler func(string)49 alreadyRunningAppRelaunchHandler func(string)
50 50
51 // activeWebViews tracks all live webviews for CSS broadcast.51 // activeWebViews tracks all live webviews for CSS broadcast.
52- activeWebViews sync.Map // map[port.WebViewID]*WebView52+ activeWebViews sync.Map // map[port.WebViewID]*WebView
53- activeCount atomic.Int3253+ browserWebViews sync.Map // map[int32]*WebView
54+ activeCount atomic.Int32
54 55
55 // shutdownNotify is signaled when the active webview count reaches 056 // shutdownNotify is signaled when the active webview count reaches 0
56 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.57 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
@@ -145,10 +146,46 @@ func (e *Engine) registerWebView(wv *WebView) {
145 e.activeCount.Add(1)146 e.activeCount.Add(1)
146 }147 }
147 148
149+func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
150+ if e == nil {
151+ return nil
152+ }
153+ current, ok := e.activeWebViews.Load(id)
154+ if !ok {
155+ return nil
156+ }
157+ wv, _ := current.(*WebView)
158+ return wv
159+}
160+
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+
148 // unregisterWebView removes a webview from the active tracking map.184 // unregisterWebView removes a webview from the active tracking map.
149 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.185 // 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) {
151 e.activeWebViews.Delete(wv.id)187 e.activeWebViews.Delete(wv.id)
188+ e.unbindBrowserWebView(browserID, wv)
152 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {189 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
153 select {190 select {
154 case e.shutdownNotify <- struct{}{}:191 case e.shutdownNotify <- struct{}{}:
@@ -432,28 +469,18 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
432 return false469 return false
433 }470 }
434 471
435- browserID := browser.GetIdentifier()472+ wv := e.webViewForBrowser(browser)
436- valid := false473+ if wv == nil {
437- e.activeWebViews.Range(func(_, value any) bool {474+ return false
438- wv, ok := value.(*WebView)475+ }
439- if !ok || wv == nil {
440- return true
441- }
442 476
443- wv.mu.RLock()477+ wv.mu.RLock()
444- wvBrowser := wv.browser478+ wvBridgeNonce := wv.bridgeNonce
445- wvBridgeNonce := wv.bridgeNonce479+ wv.mu.RUnlock()
446- wv.mu.RUnlock()480+ if wvBridgeNonce == "" {
447- if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {481+ return false
448- return true482+ }
449- }483+ return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1
450- if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
451- valid = true
452- return false
453- }
454- return true
455- })
456- return valid
457 }484 }
458 485
459 func (e *Engine) currentDownloadHandler() *downloadHandler {486 func (e *Engine) currentDownloadHandler() *downloadHandler {
M internal/infrastructure/cef/engine_init.go +100 -59
@@ -179,51 +179,80 @@ func wireEngine(
179 return nil, fmt.Errorf("GL loader: %w", err)179 return nil, fmt.Errorf("GL loader: %w", err)
180 }180 }
181 181
182- scale := detectHiDPIScale(logger)182+ mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger)
183-183+ eng.gl = gl
184- // Initialize the GPU transcoder when enabled in the loaded app config.184+ eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{
185- var mediaTranscoder port.MediaTranscoder185+ scale: detectHiDPIScale(logger),
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,
214 windowlessFrameRate: windowlessFrameRate,186 windowlessFrameRate: windowlessFrameRate,
215 transcoder: mediaTranscoder,187 transcoder: mediaTranscoder,
216 mediaClassifier: mediaClassifier,188 mediaClassifier: mediaClassifier,
217 audioOutputFactory: audioFactory,189 audioOutputFactory: audioFactory,
218 })190 })
219- pool := newWebViewPool(factory)191+ eng.pool = newWebViewPool(eng.factory)
220-
221- eng.gl = gl
222- eng.factory = factory
223- eng.pool = pool
224 eng.contentInj = newContentInjector(eng, nil)192 eng.contentInj = newContentInjector(eng, nil)
225 eng.transcoderState = transcoderState193 eng.transcoderState = transcoderState
226 194
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) {
227 messageRouter := NewMessageRouter(ctx)256 messageRouter := NewMessageRouter(ctx)
228 schemeHandler, err := newDumbSchemeHandler(257 schemeHandler, err := newDumbSchemeHandler(
229 ctx,258 ctx,
@@ -233,15 +262,25 @@ func wireEngine(
233 defaultConfigPayload,262 defaultConfigPayload,
234 )263 )
235 if err != nil {264 if err != nil {
236- purecef.Shutdown()265+ return nil, nil, err
237- return nil, err
238 }266 }
239 schemeHandler.setAssets(assets.WebUIAssets)267 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+}
240 278
279+func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) {
241 // Bridge clipboard writes from page JS → GDK system clipboard.280 // Bridge clipboard writes from page JS → GDK system clipboard.
242 // The callback is invoked on the CEF IO thread, so schedule the actual GDK281 // The callback is invoked on the CEF IO thread, so schedule the actual GDK
243 // write on the GTK main loop.282 // write on the GTK main loop.
244- schemeHandler.onClipboardSet = func(text string) {283+ return func(text string) {
245 fn := glib.SourceOnceFunc(func(_ uintptr) {284 fn := glib.SourceOnceFunc(func(_ uintptr) {
246 if display := gdk.DisplayGetDefault(); display != nil {285 if display := gdk.DisplayGetDefault(); display != nil {
247 if cb := display.GetClipboard(); cb != nil {286 if cb := display.GetClipboard(); cb != nil {
@@ -256,35 +295,37 @@ func wireEngine(
256 })295 })
257 glib.IdleAddOnce(&fn, 0)296 glib.IdleAddOnce(&fn, 0)
258 }297 }
298+}
259 299
260- schemeHandler.onEditableFocus = eng.handleEditableFocusBridge300+func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) {
261- schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest301+ registerEngineSchemeFactory(logger, "dumb", "", schemeFactory)
262-302+ registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory)
263- schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)303+}
264 304
265- result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)305+func registerEngineSchemeFactory(
266- if result != 1 {306+ logger *zerolog.Logger,
267- logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")307+ scheme, host string,
268- } else {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+ }
269 logger.Info().Msg("cef: registered dumb:// scheme handler factory")316 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
317+ return
270 }318 }
271-
272- result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
273 if result != 1 {319 if result != 1 {
274 logger.Error().320 logger.Error().
275 Int32("result", result).321 Int32("result", result).
276 Str("origin", actualInternalOrigin).322 Str("origin", actualInternalOrigin).
277 Msg("cef: failed to register internal https handler factory")323 Msg("cef: failed to register internal https handler factory")
278- } else {324+ return
279- logger.Info().
280- Str("origin", actualInternalOrigin).
281- Msg("cef: registered internal https handler factory")
282 }325 }
283-326+ logger.Info().
284- eng.messageRouter = messageRouter327+ Str("origin", actualInternalOrigin).
285- eng.schemeHandler = schemeHandler328+ Msg("cef: registered internal https handler factory")
286-
287- return eng, nil
288 }329 }
289 330
290 func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState {331 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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "sync/atomic"6 "sync/atomic"
7+ "time"
6 8
7 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
8 10
@@ -41,6 +43,13 @@ type resizeNotifiableBrowserHost interface {
41 Invalidate(purecef.PaintElementType)43 Invalidate(purecef.PaintElementType)
42 }44 }
43 45
46+var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser
47+
48+const (
49+ pendingBrowserCreateRetryDelay = 10 * time.Millisecond
50+ pendingBrowserCreateMaxPostRetries = 3
51+)
52+
44 // newWebViewFactory returns a factory that will create WebViews using the53 // newWebViewFactory returns a factory that will create WebViews using the
45 // given GL loader and HiDPI scale factor.54 // given GL loader and HiDPI scale factor.
46 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {55 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -72,17 +81,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
72 // asynchronously; the returned WebView is usable immediately but navigation81 // asynchronously; the returned WebView is usable immediately but navigation
73 // will fail with errNoBrowser until OnAfterCreated fires.82 // will fail with errNoBrowser until OnAfterCreated fires.
74 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {83 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)
76 105
106+ return wv, nil
107+}
108+
109+func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
110+ id := port.WebViewID(f.nextID.Add(1))
77 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)111 pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)
78 112
79 wv := &WebView{113 wv := &WebView{
80- id: id,114+ id: id,
81- ctx: ctx,115+ ctx: ctx,
82- engine: f.engine,116+ engine: f.engine,
83- pipeline: pipeline,117+ pipeline: pipeline,
84- audioOutputFactory: f.audioOutputFactory,118+ audioOutputFactory: f.audioOutputFactory,
85- resizeReconciler: newResizeReconciler(ctx, id),119+ resizeReconciler: newResizeReconciler(ctx, id),
120+ windowlessFrameRate: f.windowlessFrameRate,
121+ backgroundColor: f.bgColor.Load(),
86 }122 }
87 123
88 var transcodingHandler purecef.ResourceRequestHandler124 var transcodingHandler purecef.ResourceRequestHandler
@@ -129,30 +165,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
129 165
130 // Build a CEF client backed by our handlerSet.166 // Build a CEF client backed by our handlerSet.
131 // Store on WebView to prevent GC collection before CEF AddRef's it.167 // Store on WebView to prevent GC collection before CEF AddRef's it.
132- client := purecef.NewClient(wv.handlers)168+ wv.client = purecef.NewClient(wv.handlers)
133- wv.client = client169+ return wv
134-
135- // Configure WindowInfo for off-screen rendering (OSR).
136- windowInfo := purecef.NewWindowInfo()
137- windowInfo.WindowlessRenderingEnabled = 1
138- if externalBeginFrameEnabled() {
139- windowInfo.ExternalBeginFrameEnabled = 1
140- }
141-
142- // Configure BrowserSettings.
143- settings := purecef.NewBrowserSettings()
144- settings.WindowlessFrameRate = f.windowlessFrameRate
145- settings.LocalStorage = 1 // CEF_STATE_ENABLED
146- if bg := f.bgColor.Load(); bg != 0 {
147- settings.BackgroundColor = bg
148- }
149-
150- // Defer browser creation until the GL area has a non-zero size.
151- // CEF requires GetViewRect to return a non-empty rect, but the GL area
152- // is not yet realized at this point.
153- f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings)
154-
155- return wv, nil
156 }170 }
157 171
158 func (f *WebViewFactory) configureInitialBrowserCreation(172 func (f *WebViewFactory) configureInitialBrowserCreation(
@@ -166,60 +180,8 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
166 }180 }
167 181
168 pipeline.onFirstResize = func(w, h int32) {182 pipeline.onFirstResize = func(w, h int32) {
169- log := logging.FromContext(ctx)183+ logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
170- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")184+ f.postPendingBrowserCreate(ctx, wv, w, h)
171- pc := wv.takePendingCreate()
172- if pc == nil {
173- return
174- }
175-
176- task := purecef.NewTask(cefTaskFunc(func() {
177- if wv.destroyed.Load() {
178- log.Debug().Msg("cef: skipping browser creation for destroyed webview")
179- return
180- }
181- pendingURL := wv.pendingNavigationURI()
182- // Always bootstrap OSR browsers on about:blank first. Starting directly on
183- // the target URL can race host visibility/focus setup and strand the
184- // renderer without an initial paint. We replay pending navigation from
185- // OnAfterCreated once the host is fully wired.
186- initialURL := "about:blank"
187- result := purecef.BrowserHostCreateBrowser(
188- pc.windowInfo,
189- pc.client,
190- initialURL,
191- pc.settings,
192- nil, // extraInfo
193- nil, // requestContext
194- )
195- log.Debug().
196- Str("initial_url", initialURL).
197- Str("pending_url", pendingURL).
198- Msg("cef: BrowserHostCreateBrowser initial URL")
199- if f.engine != nil {
200- f.engine.recordBrowserCreateRequest(w, h, result)
201- }
202- log.Debug().
203- Int32("result", result).
204- Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
205- Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
206- Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
207- Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
208- Bool("client_nil", pc.client == nil).
209- Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
210- }))
211-
212- postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
213- if postResult != 1 {
214- wv.mu.Lock()
215- if wv.pendingCreate == nil {
216- wv.pendingCreate = pc
217- }
218- wv.mu.Unlock()
219- log.Error().
220- Int32("result", postResult).
221- Msg("cef: failed to post initial browser creation to CEF UI thread")
222- }
223 }185 }
224 186
225 // On subsequent resizes, notify CEF so it re-queries GetViewRect.187 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
@@ -268,6 +230,88 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
268 }230 }
269 }231 }
270 232
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+
271 func notifyBrowserResize(host resizeNotifiableBrowserHost) {315 func notifyBrowserResize(host resizeNotifiableBrowserHost) {
272 if host == nil {316 if host == nil {
273 return317 return
@@ -276,9 +320,60 @@ func notifyBrowserResize(host resizeNotifiableBrowserHost) {
276 host.Invalidate(purecef.PaintElementTypePetView)320 host.Invalidate(purecef.PaintElementTypePetView)
277 }321 }
278 322
279-// CreateRelated creates a WebView that shares session/cookies with the parent.323+// CreateRelated creates a popup shell for a native CEF popup. The popup
280-// TODO(phase2): look up the parent browser by parentID and use the same324+// browser itself is created by CEF from OnBeforePopup and later bound to this
281-// request context so cookies and session state are shared.325+// shell in OnAfterCreated.
282-func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {326+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
283- return nil, ErrRelatedWebViewUnsupported327+ 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
284 }379 }
M internal/infrastructure/cef/factory_resize_test.go +70 -0
@@ -1,7 +1,9 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
6+ "time"
5 7
6 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -26,3 +28,71 @@ func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) {
26 28
27 require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls)29 require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls)
28 }30 }
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 (
17 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"17 rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18 rendererBridgeActionFocusSync = "focus_sync"18 rendererBridgeActionFocusSync = "focus_sync"
19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"19 rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionPopupOpen = "popup_open"
21+ rendererBridgeActionPopupNavigate = "popup_navigate"
22+ rendererBridgeActionPopupClose = "popup_close"
20 rendererBridgeActionReady = "bridge_ready"23 rendererBridgeActionReady = "bridge_ready"
21 rendererBridgeExtensionName = "dumber.renderer_bridge"24 rendererBridgeExtensionName = "dumber.renderer_bridge"
22 )25 )
23 26
24 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate27 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
25 28
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-
221 type rendererBridgeProcessHandler struct {29 type rendererBridgeProcessHandler struct {
222 registerOnce sync.Once30 registerOnce sync.Once
223 bridgeHandler purecef.V8Handler31 bridgeHandler purecef.V8Handler
@@ -338,6 +146,23 @@ type rendererBridgeExplicitTextCopyPayload struct {
338 Action string `json:"action"`146 Action string `json:"action"`
339 }147 }
340 148
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+
341 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {166 func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
342 var req rendererBridgeExplicitTextCopyPayload167 var req rendererBridgeExplicitTextCopyPayload
343 if len(payload) == 0 {168 if len(payload) == 0 {
@@ -355,6 +180,38 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
355 return req, nil180 return req, nil
356 }181 }
357 182
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+
358 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {215 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
359 logger := logging.FromContext(e.currentContext())216 logger := logging.FromContext(e.currentContext())
360 task := purecef.NewTask(cefTaskFunc(func() {217 task := purecef.NewTask(cefTaskFunc(func() {
M internal/infrastructure/cef/renderer_bridge_test.go +82 -0
@@ -97,6 +97,17 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")97 require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
98 }98 }
99 99
100+func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101+ require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102+ require.Contains(t, rendererBridgeExtensionJS, "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+
100 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {111 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")112 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")113 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
@@ -130,6 +141,77 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
130 require.Equal(t, "cut", req.Action)141 require.Equal(t, "cut", req.Action)
131 }142 }
132 143
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+
133 func parseCEFPackageFiles(t *testing.T) []*ast.File {215 func parseCEFPackageFiles(t *testing.T) []*ast.File {
134 t.Helper()216 t.Helper()
135 entries, err := os.ReadDir(".")217 entries, err := os.ReadDir(".")
M internal/infrastructure/cef/runtime_config.go +2 -1
@@ -5,11 +5,12 @@ import (
5 "errors"5 "errors"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
8 )9 )
9 10
10 var (11 var (
11 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")12 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12- ErrRelatedWebViewUnsupported = errors.New("cef: related popup webviews are not supported yet")13+ ErrRelatedWebViewUnsupported = domainerrors.ErrRelatedWebViewUnsupported
13 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")14 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
14 )15 )
15 16
M internal/infrastructure/cef/scheme_handler.go +160 -28
@@ -66,6 +66,15 @@ type dumbSchemeHandler struct {
66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
67 onEditableFocus func(browser purecef.Browser)67 onEditableFocus func(browser purecef.Browser)
68 68
69+ // onPopupOpen/onPopupNavigate/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+
69 // bridgeNonceValidator checks whether a bridge nonce belongs to the active78 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
70 // browser/navigation context that issued the request.79 // browser/navigation context that issued the request.
71 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool80 bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
@@ -130,9 +139,10 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
130 }139 }
131 140
132 // Route API requests before redirecting conceptual dumb:// URLs to the141 // Route API requests before redirecting conceptual dumb:// URLs to the
133- // internal HTTPS origin. External pages use dumb://api/... for clipboard and142+ // internal HTTPS origin. External pages use dumb://api/... for clipboard,
134- // focus bridges specifically to bypass page CSP; redirecting those requests143+ // focus, and popup bridges specifically to bypass page CSP; redirecting
135- // to https://dumber.invalid makes site connect-src policies block them.144+ // those requests to https://dumber.invalid makes site connect-src policies
145+ // block them.
136 if apiPath, ok := resolveAPIPath(u); ok {146 if apiPath, ok := resolveAPIPath(u); ok {
137 return h.handleAPI(browser, method, apiPath, request)147 return h.handleAPI(browser, method, apiPath, request)
138 }148 }
@@ -156,37 +166,73 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
156 if strings.EqualFold(method, http.MethodOptions) {166 if strings.EqualFold(method, http.MethodOptions) {
157 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)167 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
158 }168 }
159-169+ if isAPIGetMethod(method) {
160- switch {170+ if handler, ok := h.handleAPIGet(path, request); ok {
161- case path == "/api/message" && strings.EqualFold(method, "POST"):171+ return handler
162- body := readBodyFromHeader(request)
163- if body == nil {
164- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
165 }172 }
166- resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)173+ }
167- if err != nil {174+ if isAPIPostMethod(method) {
168- return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})175+ if handler, ok := h.handleAPIPost(browser, path, request); ok {
176+ return handler
169 }177 }
170- return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)178+ }
171-179+ return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
172- case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):180+}
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)
180 181
181- case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):182+func isAPIGetMethod(method string) bool {
182- return h.handleClipboardSet(request, browser)183+ return method == "" || strings.EqualFold(method, http.MethodGet)
184+}
183 185
184- case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):186+func isAPIPostMethod(method string) bool {
185- return h.handleFocusSync(request, browser)187+ return strings.EqualFold(method, http.MethodPost)
188+}
186 189
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
187 default:198 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()})
189 }234 }
235+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
190 }236 }
191 237
192 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {238 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
@@ -206,7 +252,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
206 }252 }
207 253
208 sourceParsed, err := url.Parse(sourceURL)254 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) {
210 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})256 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
211 }257 }
212 258
@@ -331,6 +377,92 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
331 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})377 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
332 }378 }
333 379
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+
334 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {466 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {467 if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336 return false468 return false
M internal/infrastructure/cef/scheme_handler_test.go +236 -0
@@ -70,6 +70,20 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
70 require.JSONEq(t, `{"text":"copied from js"}`, string(body))70 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
71 }71 }
72 72
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+
73 func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {87 func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
74 oldNewResourceHandler := cefNewResourceHandler88 oldNewResourceHandler := cefNewResourceHandler
75 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }89 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -235,6 +249,228 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
235 require.Positive(t, responseLength)249 require.Positive(t, responseLength)
236 }250 }
237 251
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+
238 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {474 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
239 oldNewResourceHandler := cefNewResourceHandler475 oldNewResourceHandler := cefNewResourceHandler
240 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }476 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/cef/webview.go +545 -6
@@ -23,9 +23,12 @@ import (
23 23
24 // Compile-time interface checks.24 // Compile-time interface checks.
25 var (25 var (
26- _ port.WebView = (*WebView)(nil)26+ _ port.WebView = (*WebView)(nil)
27- _ port.NativeWidgetProvider = (*WebView)(nil)27+ _ port.NativeWidgetProvider = (*WebView)(nil)
28- _ port.DevToolsOpener = (*WebView)(nil)28+ _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.PopupLifecycleCapable = (*WebView)(nil)
30+ _ port.PopupOpenerCapable = (*WebView)(nil)
31+ _ port.OAuthCallbackCapable = (*WebView)(nil)
29 )32 )
30 33
31 // errDestroyed is returned when an operation is attempted on a destroyed WebView.34 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -38,6 +41,7 @@ const (
38 clipboardSelectionDebounceDelay = 300 * time.Millisecond41 clipboardSelectionDebounceDelay = 300 * time.Millisecond
39 pendingNavigationRetryDelay = 50 * time.Millisecond42 pendingNavigationRetryDelay = 50 * time.Millisecond
40 pendingNavigationMaxRetries = 8043 pendingNavigationMaxRetries = 80
44+ nativePopupAttachFallbackDelay = 250 * time.Millisecond
41 )45 )
42 46
43 type stoppableTimer interface {47 type stoppableTimer interface {
@@ -94,6 +98,35 @@ type WebView struct {
94 mu sync.RWMutex98 mu sync.RWMutex
95 callbacks *port.WebViewCallbacks99 callbacks *port.WebViewCallbacks
96 100
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+
97 // State cache (mutex-protected).130 // State cache (mutex-protected).
98 uri string131 uri string
99 title string132 title string
@@ -126,6 +159,11 @@ type WebView struct {
126 audioPlaying atomic.Bool159 audioPlaying atomic.Bool
127 zoomFactor atomic.Value // float64, initialized to 1.0160 zoomFactor atomic.Value // float64, initialized to 1.0
128 161
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+
129 // Audio output factory and active stream.167 // Audio output factory and active stream.
130 audioOutputFactory port.AudioOutputFactory168 audioOutputFactory port.AudioOutputFactory
131 audioStreamMu sync.Mutex169 audioStreamMu sync.Mutex
@@ -139,9 +177,11 @@ type WebView struct {
139 // pendingBrowserCreate holds the parameters needed to create a CEF browser,177 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
140 // deferred until the GL area has a non-zero size.178 // deferred until the GL area has a non-zero size.
141 type pendingBrowserCreate struct {179 type pendingBrowserCreate struct {
142- windowInfo *purecef.WindowInfo180+ windowInfo *purecef.WindowInfo
143- client purecef.RawClient181+ client purecef.RawClient
144- settings *purecef.BrowserSettings182+ settings *purecef.BrowserSettings
183+ extraInfo purecef.DictionaryValue
184+ postTaskRetries int
145 }185 }
146 186
147 type cefTaskFunc func()187 type cefTaskFunc func()
@@ -465,6 +505,408 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
465 wv.callbacks = cb505 wv.callbacks = cb
466 }506 }
467 507
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+
468 // ---------------------------------------------------------------------------910 // ---------------------------------------------------------------------------
469 // JavaScript / Appearance911 // JavaScript / Appearance
470 // ---------------------------------------------------------------------------912 // ---------------------------------------------------------------------------
@@ -509,6 +951,19 @@ func (wv *WebView) rotateBridgeNonce() string {
509 return nonce951 return nonce
510 }952 }
511 953
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+
512 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).967 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
513 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {968 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
514 script := fmt.Sprintf(969 script := fmt.Sprintf(
@@ -536,6 +991,23 @@ func (wv *WebView) Destroy() {
536 if !wv.destroyed.CompareAndSwap(false, true) {991 if !wv.destroyed.CompareAndSwap(false, true) {
537 return992 return
538 }993 }
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()
539 wv.cancelSelectionDebounce()1011 wv.cancelSelectionDebounce()
540 if wv.resizeReconciler != nil {1012 if wv.resizeReconciler != nil {
541 wv.resizeReconciler.stop()1013 wv.resizeReconciler.stop()
@@ -547,6 +1019,8 @@ func (wv *WebView) Destroy() {
547 wv.mu.RUnlock()1019 wv.mu.RUnlock()
548 if host != nil {1020 if host != nil {
549 host.CloseBrowser(1)1021 host.CloseBrowser(1)
1022+ } else {
1023+ wv.runCloseCallbacks()
550 }1024 }
551 if wv.pipeline != nil {1025 if wv.pipeline != nil {
552 wv.pipeline.destroy()1026 wv.pipeline.destroy()
@@ -593,6 +1067,7 @@ func (wv *WebView) updateURI(uri string) {
593 cb.OnURIChanged(uri)1067 cb.OnURIChanged(uri)
594 })1068 })
595 }1069 }
1070+ wv.runNavigationCallbacks(uri)
596 }1071 }
597 1072
598 func (wv *WebView) updateTitle(title string) {1073 func (wv *WebView) updateTitle(title string) {
@@ -1119,6 +1594,70 @@ func (wv *WebView) runOnGTK(fn func()) {
1119 glib.IdleAddOnce(cb, 0)1594 glib.IdleAddOnce(cb, 0)
1120 }1595 }
1121 1596
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+
1122 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {1661 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
1123 wv.mu.Lock()1662 wv.mu.Lock()
1124 defer wv.mu.Unlock()1663 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) {
55 defer wv.mu.RUnlock()55 defer wv.mu.RUnlock()
56 require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)56 require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)
57 }57 }
58+
59+func TestWebViewEnsureBridgeNonce_ReusesExistingNonce(t *testing.T) {
60+ wv := &WebView{bridgeNonce: "existing-nonce"}
61+ require.Equal(t, "existing-nonce", wv.ensureBridgeNonce())
62+}
M internal/infrastructure/cef/webview_handlers.go +160 -39
@@ -107,6 +107,27 @@ func (h *handlerSet) OnProcessMessageReceived(
107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))107 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
108 case rendererBridgeActionFocusSync:108 case rendererBridgeActionFocusSync:
109 h.wv.engine.handleEditableFocusBridge(browser)109 h.wv.engine.handleEditableFocusBridge(browser)
110+ case rendererBridgeActionPopupOpen:
111+ req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
112+ if err != nil {
113+ log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
114+ return 1
115+ }
116+ h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, 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)
110 case rendererBridgeActionReady:131 case rendererBridgeActionReady:
111 log.Debug().132 log.Debug().
112 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).133 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
@@ -580,6 +601,20 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
580 if frame == nil || !frame.IsMain() {601 if frame == nil || !frame.IsMain() {
581 return602 return
582 }603 }
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+ }
583 if h.wv != nil && h.wv.ctx != nil {618 if h.wv != nil && h.wv.ctx != nil {
584 logging.FromContext(h.wv.ctx).Debug().619 logging.FromContext(h.wv.ctx).Debug().
585 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).620 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
@@ -651,13 +686,15 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
651 // ===========================================================================686 // ===========================================================================
652 687
653 // OnBeforePopup intercepts popup requests (target="_blank", window.open).688 // OnBeforePopup intercepts popup requests (target="_blank", window.open).
654-// CEF OSR cannot create popup windows, so we fire the OnCreate callback689+// When the coordinator returns a related CEF popup shell we hand that shell's
655-// to let the coordinator open the link in a new stacked pane.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.
656 func (h *handlerSet) OnBeforePopup(693 func (h *handlerSet) OnBeforePopup(
657- _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,694+ _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
658 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,695 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
659- _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings,696+ windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
660- _ *purecef.DictionaryValue, _ *bool,697+ _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
661 ) bool {698 ) bool {
662 if targetURL == "" {699 if targetURL == "" {
663 return true700 return true
@@ -666,23 +703,47 @@ func (h *handlerSet) OnBeforePopup(
666 h.wv.mu.RLock()703 h.wv.mu.RLock()
667 cb := h.wv.callbacks704 cb := h.wv.callbacks
668 h.wv.mu.RUnlock()705 h.wv.mu.RUnlock()
706+ if cb == nil || cb.OnCreate == nil {
707+ return true
708+ }
669 709
670- if cb != nil && cb.OnCreate != nil {710+ requestNoJavaScriptAccess := false
671- req := port.PopupRequest{711+ if noJavaScriptAccess != nil {
672- TargetURI: targetURL,712+ requestNoJavaScriptAccess = *noJavaScriptAccess
673- FrameName: targetFrameName,713+ }
674- IsUserGesture: userGesture != 0,714+ var popup port.WebView
675- ParentViewID: h.wv.id,715+ h.wv.runOnGTKSync(func() {
676- }716+ popup = cb.OnCreate(port.PopupRequest{
677- h.wv.runOnGTK(func() {717+ TargetURI: targetURL,
678- cb.OnCreate(req)718+ FrameName: targetFrameName,
719+ IsUserGesture: userGesture != 0,
720+ NoJavaScriptAccess: requestNoJavaScriptAccess,
721+ ParentViewID: h.wv.id,
679 })722 })
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
680 }732 }
681 733
682- return true // always block CEF popup; the coordinator handles the new pane734+ 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
683 }740 }
684 741
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+}
686 747
687 func (h *handlerSet) OnBeforeDevToolsPopup(748 func (h *handlerSet) OnBeforeDevToolsPopup(
688 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,749 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
@@ -698,45 +759,88 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
698 return759 return
699 }760 }
700 browserID := browser.GetIdentifier()761 browserID := browser.GetIdentifier()
701- log.Debug().762+ log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
702- Int32("browser_id", browserID).763+
703- Msg("cef: OnAfterCreated")
704- if h.wv.engine != nil {
705- h.wv.engine.recordBrowserAfterCreated(browser)
706- h.wv.engine.registerWebView(h.wv)
707- }
708 host := browser.GetHost()764 host := browser.GetHost()
709 if host == nil {765 if host == nil {
710 log.Warn().Msg("cef: OnAfterCreated returned nil host")766 log.Warn().Msg("cef: OnAfterCreated returned nil host")
711 return767 return
712 }768 }
713 769
714- shouldSyncFocus := false770+ state := h.attachAfterCreatedBrowser(browser, host, browserID)
715- hasPendingNavigation := false771+ 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{}
716 h.wv.mu.Lock()789 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+
717 h.wv.browser = browser801 h.wv.browser = browser
718 h.wv.host = host802 h.wv.host = host
803+ h.wv.pendingCreate = nil
719 h.wv.input.setHost(host)804 h.wv.input.setHost(host)
720 if h.wv.findCtrl != nil {805 if h.wv.findCtrl != nil {
721 h.wv.findCtrl.setHost(host)806 h.wv.findCtrl.setHost(host)
722 }807 }
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
723 813
724 // Mark browser as visible — CEF OSR starts in hidden state and suppresses814 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
725 // painting/caret updates until explicitly told the browser is shown.815 // painting/caret updates until explicitly told the browser is shown.
726 host.WasHidden(0)816 host.WasHidden(0)
817+ state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
818+ state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
819+ return state
820+}
727 821
728- // If GTK focus entered before the browser existed, reassert browser focus822+func (h *handlerSet) afterCreatedShouldSyncFocus() bool {
729- // now. Otherwise CEF may stay internally unfocused and suppress caret paint.823+ if h.wv.input == nil {
730- if h.wv.input != nil {824+ return false
731- shouldSyncFocus = h.wv.input.hasGTKFocus()
732- if !shouldSyncFocus && h.wv.input.glArea != nil {
733- shouldSyncFocus = h.wv.input.glArea.HasFocus()
734- }
735 }825 }
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+}
737 831
738- h.wv.mu.Unlock()832+func (h *handlerSet) finishAfterCreated(
739- if shouldSyncFocus {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 {
740 syncWindowlessBrowserFocus(host)844 syncWindowlessBrowserFocus(host)
741 } else {845 } else {
742 // Request an initial OSR frame even before the first real navigation846 // Request an initial OSR frame even before the first real navigation
@@ -744,11 +848,23 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
744 // startup path relied on and prevents the render pipeline from staying idle.848 // startup path relied on and prevents the render pipeline from staying idle.
745 host.Invalidate(purecef.PaintElementTypePetView)849 host.Invalidate(purecef.PaintElementTypePetView)
746 }850 }
747- if hasPendingNavigation {851+ if state.hasPendingNavigation {
748 h.wv.schedulePendingNavigationReplay(0)852 h.wv.schedulePendingNavigationReplay(0)
749 }853 }
750-854+ if state.nativePopupParent != nil && state.nativePopupID != 0 {
855+ state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
856+ }
751 h.wv.scheduleStartBeginFrameLoop()857 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
752 }868 }
753 869
754 // DoClose returns false to allow the default close behavior.870 // DoClose returns false to allow the default close behavior.
@@ -757,9 +873,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
757 }873 }
758 874
759 // OnBeforeClose fires the OnClose callback.875 // OnBeforeClose fires the OnClose callback.
760-func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {876+func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
761 if h.wv.engine != nil {877 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)
763 }883 }
764 h.wv.mu.Lock()884 h.wv.mu.Lock()
765 h.wv.browser = nil885 h.wv.browser = nil
@@ -780,6 +900,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
780 cb.OnClose()900 cb.OnClose()
781 })901 })
782 }902 }
903+ h.wv.runCloseCallbacks()
783 }904 }
784 905
785 // ===========================================================================906 // ===========================================================================
M internal/infrastructure/cef/webview_handlers_test.go +162 -0
@@ -101,6 +101,159 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
101 return nil101 return nil
102 }102 }
103 103
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+
104 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {257 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
105 wv := &WebView{ctx: context.Background()}258 wv := &WebView{ctx: context.Background()}
106 var gotEvents []port.LoadEvent259 var gotEvents []port.LoadEvent
@@ -282,6 +435,15 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
282 require.Equal(t, "free selection", recorder.selection.Text)435 require.Equal(t, "free selection", recorder.selection.Text)
283 }436 }
284 437
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+
285 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {447 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
286 scheduler := &controlledSelectionScheduler{}448 scheduler := &controlledSelectionScheduler{}
287 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)449 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 (
30 var _ port.WebView = (*WebView)(nil)30 var _ port.WebView = (*WebView)(nil)
31 var _ port.DevToolsOpener = (*WebView)(nil)31 var _ port.DevToolsOpener = (*WebView)(nil)
32 var _ port.Printer = (*WebView)(nil)32 var _ port.Printer = (*WebView)(nil)
33+var _ port.PopupLifecycleCapable = (*WebView)(nil)
33 var _ port.OAuthCallbackCapable = (*WebView)(nil)34 var _ port.OAuthCallbackCapable = (*WebView)(nil)
34 35
35 // WebViewID is an alias to port.WebViewID for clean architecture compliance.36 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -1584,15 +1585,19 @@ func (wv *WebView) Show() {
1584 wv.inner.SetVisible(true)1585 wv.inner.SetVisible(true)
1585 }1586 }
1586 1587
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+
1587 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.1592 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1588-// It implements port.PopupCapable.1593+// It implements port.PopupLifecycleCapable.
1589 func (wv *WebView) SetOnReadyToShow(fn func()) {1594 func (wv *WebView) SetOnReadyToShow(fn func()) {
1590 wv.OnReadyToShow = fn1595 wv.OnReadyToShow = fn
1591 }1596 }
1592 1597
1593 // SetOnClose composes fn with any existing OnClose handler so multiple callers1598 // SetOnClose composes fn with any existing OnClose handler so multiple callers
1594 // can each register a close callback without overwriting one another.1599 // can each register a close callback without overwriting one another.
1595-// It implements port.PopupCapable.1600+// It implements port.PopupLifecycleCapable.
1596 func (wv *WebView) SetOnClose(fn func()) {1601 func (wv *WebView) SetOnClose(fn func()) {
1597 existing := wv.OnClose1602 existing := wv.OnClose
1598 if existing == nil {1603 if existing == nil {
M internal/ui/coordinator/content/coordinator.go +75 -32
@@ -3,7 +3,6 @@ package content
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6- "time"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
@@ -29,8 +28,9 @@ type Coordinator struct {
29 settingsApplier port.SettingsApplier // optional: nil if engine doesn't support28 settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
30 filterApplier port.FilterApplier // optional: nil if engine doesn't support29 filterApplier port.FilterApplier // optional: nil if engine doesn't support
31 30
32- webViews map[entity.PaneID]port.WebView31+ webViews map[entity.PaneID]port.WebView
33- webViewsMu sync.RWMutex32+ webViewPaneIDs map[port.WebViewID]entity.PaneID
33+ webViewsMu sync.RWMutex
34 34
35 activePaneOverride entity.PaneID35 activePaneOverride entity.PaneID
36 activePaneOverrideMu sync.RWMutex36 activePaneOverrideMu sync.RWMutex
@@ -81,23 +81,10 @@ type Coordinator struct {
81 // Gesture action handler for mouse button navigation81 // Gesture action handler for mouse button navigation
82 gestureActionHandler input.ActionHandler82 gestureActionHandler input.ActionHandler
83 83
84- // Popup handling84+ // Popup handling stays in a dedicated UI-layer manager so popup-specific
85- factory port.WebViewFactory85+ // pane state does not bloat the main coordinator.
86- popupConfig *entity.PopupBehaviorConfig86+ popups *popupManager
87- pendingPopups map[port.WebViewID]*PendingPopup87+ popupsOnce sync.Once
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
101 88
102 // Idle inhibitor for fullscreen video playback89 // Idle inhibitor for fullscreen video playback
103 idleInhibitor port.IdleInhibitor90 idleInhibitor port.IdleInhibitor
@@ -122,14 +109,6 @@ type pendingThemeUpdate struct {
122 cssText string109 cssText string
123 }110 }
124 111
125-type popupOAuthState struct {
126- ParentPaneID entity.PaneID
127- CallbackURI string
128- Success bool
129- Error bool
130- Seen bool
131-}
132-
133 // PermissionActivityState represents the visible state for media permission activity.112 // PermissionActivityState represents the visible state for media permission activity.
134 type PermissionActivityState string113 type PermissionActivityState string
135 114
@@ -162,17 +141,28 @@ func NewCoordinator(
162 zoomUC: zoomUC,141 zoomUC: zoomUC,
163 permissionUC: permissionUC,142 permissionUC: permissionUC,
164 webViews: make(map[entity.PaneID]port.WebView),143 webViews: make(map[entity.PaneID]port.WebView),
144+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
165 paneTitles: make(map[entity.PaneID]string),145 paneTitles: make(map[entity.PaneID]string),
166 navOrigins: make(map[entity.PaneID]string),146 navOrigins: make(map[entity.PaneID]string),
167 pendingReveal: make(map[entity.PaneID]bool),147 pendingReveal: make(map[entity.PaneID]bool),
168 pendingScriptRefresh: make(map[entity.PaneID]bool),148 pendingScriptRefresh: make(map[entity.PaneID]bool),
169 pendingThemePanes: make(map[entity.PaneID]bool),149 pendingThemePanes: make(map[entity.PaneID]bool),
170 getActiveWS: getActiveWS,150 getActiveWS: getActiveWS,
171- pendingPopups: make(map[port.WebViewID]*PendingPopup),151+ popups: newPopupManager(),
172- namedPopups: make(map[namedPopupKey]*namedPopupState),152+ }
173- popupOAuth: make(map[port.WebViewID]*popupOAuthState),153+}
174- popupRefresh: make(map[entity.PaneID]*time.Timer),154+
155+func (c *Coordinator) ensurePopupManager() *popupManager {
156+ if c == nil {
157+ return nil
175 }158 }
159+ c.popupsOnce.Do(func() {
160+ if c.popups == nil {
161+ c.popups = newPopupManager()
162+ }
163+ })
164+ c.popups.ensureInitialized()
165+ return c.popups
176 }166 }
177 167
178 // SetOnTitleUpdated sets the callback for title changes (for history persistence).168 // SetOnTitleUpdated sets the callback for title changes (for history persistence).
@@ -308,7 +298,16 @@ func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
308 298
309 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {299 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
310 c.webViewsMu.Lock()300 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+ }
311 c.webViews[paneID] = wv307 c.webViews[paneID] = wv
308+ if wv != nil && c.webViewPaneIDs != nil {
309+ c.webViewPaneIDs[wv.ID()] = paneID
310+ }
312 c.webViewsMu.Unlock()311 c.webViewsMu.Unlock()
313 }312 }
314 313
@@ -317,5 +316,49 @@ func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
317 defer c.webViewsMu.Unlock()316 defer c.webViewsMu.Unlock()
318 wv := c.webViews[paneID]317 wv := c.webViews[paneID]
319 delete(c.webViews, paneID)318 delete(c.webViews, paneID)
319+ if wv != nil && c.webViewPaneIDs != nil {
320+ delete(c.webViewPaneIDs, wv.ID())
321+ }
320 return wv322 return wv
321 }323 }
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) {
206 }206 }
207 }207 }
208 208
209-func composeOnLoadChanged(209+func composeOnLoadChanged(existing, next func(port.LoadEvent)) func(port.LoadEvent) {
210- existing func(port.LoadEvent),
211- next func(port.LoadEvent),
212-) func(port.LoadEvent) {
213 if existing == nil {210 if existing == nil {
214 return next211 return next
215 }212 }
@@ -227,6 +224,14 @@ func composeOnLoadChanged(
227 // For providers using postMessage (like Google Sign-In), we rely on the provider calling224 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
228 // window.close() which triggers WebKit's close signal.225 // window.close() which triggers WebKit's close signal.
229 // A long safety timeout (30s) catches popups that get stuck.226 // A long safety timeout (30s) catches popups that get stuck.
227+func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
228+ if wv == nil {
229+ return false
230+ }
231+ opener, ok := wv.(port.PopupOpenerCapable)
232+ return ok && opener.HasActivePopupOpenerBridge()
233+}
234+
230 func (c *Coordinator) setupOAuthAutoClose(235 func (c *Coordinator) setupOAuthAutoClose(
231 ctx context.Context,236 ctx context.Context,
232 paneID entity.PaneID,237 paneID entity.PaneID,
@@ -287,8 +292,7 @@ func (c *Coordinator) setupOAuthAutoClose(
287 })292 })
288 }293 }
289 294
290- requestOAuthClose := func(uri string, reason string) {295+ requestOAuthClose := func(reason string) {
291- c.capturePopupOAuthState(popupID, uri)
292 cancelSafetyTimer()296 cancelSafetyTimer()
293 log.Info().297 log.Info().
294 Str("pane", string(paneID)).298 Str("pane", string(paneID)).
@@ -309,13 +313,38 @@ func (c *Coordinator) setupOAuthAutoClose(
309 313
310 // Start safety timer immediately.314 // Start safety timer immediately.
311 startSafetyTimer()315 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")
312 322
313 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.323 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314 oauthWV.AddNavigationCallback(func(uri string) {324 oauthWV.AddNavigationCallback(func(uri string) {
315- if ShouldAutoClose(uri) {325+ if !ShouldAutoClose(uri) {
316- requestOAuthClose(uri, "navigation")326+ return
327+ }
328+ c.capturePopupOAuthState(popupID, uri)
329+ if deferCloseOnNavigation {
330+ cancelSafetyTimer()
331+ return
317 }332 }
333+ requestOAuthClose("navigation")
318 })334 })
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+ }
319 348
320 // Cancel safety timer on any close path.349 // Cancel safety timer on any close path.
321 oauthWV.AddCloseCallback(func() {350 oauthWV.AddCloseCallback(func() {
@@ -323,41 +352,22 @@ func (c *Coordinator) setupOAuthAutoClose(
323 })352 })
324 }353 }
325 354
326-func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {355+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
327- c.popupMu.Lock()356+ c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
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- }
335 }357 }
336 358
337 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {359 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
338- c.popupMu.Lock()360+ c.ensurePopupManager().capturePopupOAuthState(popupID, uri)
339- defer c.popupMu.Unlock()361+}
340-
341- state, ok := c.popupOAuth[popupID]
342- if !ok {
343- return
344- }
345 362
346- state.Seen = true363+func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
347- state.CallbackURI = uri364+ c.ensurePopupManager().capturePopupOAuthMessage(popupID)
348- state.Success = IsOAuthSuccess(uri)
349- state.Error = IsOAuthError(uri)
350 }365 }
351 366
352 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {367 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
353 log := logging.FromContext(ctx)368 log := logging.FromContext(ctx)
354 369
355- c.popupMu.Lock()370+ state, ok := c.ensurePopupManager().takePopupOAuthState(popupID)
356- state, ok := c.popupOAuth[popupID]
357- if ok {
358- delete(c.popupOAuth, popupID)
359- }
360- c.popupMu.Unlock()
361 371
362 if !ok || state == nil || !state.Seen {372 if !ok || state == nil || !state.Seen {
363 return373 return
@@ -370,42 +380,78 @@ func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.We
370 Bool("oauth_error", state.Error).380 Bool("oauth_error", state.Error).
371 Msg("popup oauth result captured on close")381 Msg("popup oauth result captured on close")
372 382
373- if !state.Success || state.ParentPaneID == "" {383+ if state.ParentPaneID == "" {
374 return384 return
375 }385 }
376 386
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+ )
378 }415 }
379 416
380-func (c *Coordinator) scheduleParentPaneRefresh(417+func (c *Coordinator) scheduleParentPaneOAuthResumeAttempt(
381 ctx context.Context,418 ctx context.Context,
382 parentPaneID entity.PaneID,419 parentPaneID entity.PaneID,
383 popupID port.WebViewID,420 popupID port.WebViewID,
421+ parentURIAtOpen, callbackURI string,
422+ remainingGrace int,
384 ) {423 ) {
385- c.popupMu.Lock()424+ c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() {
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()
396 cb := glib.SourceFunc(func(_ uintptr) bool {425 cb := glib.SourceFunc(func(_ uintptr) bool {
397- c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)426+ c.resumeParentPaneAfterOAuthAttempt(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI, remainingGrace)
398 return false427 return false
399 })428 })
400 glib.IdleAdd(&cb, 0)429 glib.IdleAdd(&cb, 0)
401 })430 })
402- c.popupMu.Unlock()
403 }431 }
404 432
405-func (c *Coordinator) refreshPaneAfterOAuth(433+func (c *Coordinator) resumeParentPaneAfterOAuth(
406 ctx context.Context,434 ctx context.Context,
407 parentPaneID entity.PaneID,435 parentPaneID entity.PaneID,
408 popupID port.WebViewID,436 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,
409 ) {455 ) {
410 log := logging.FromContext(ctx)456 log := logging.FromContext(ctx)
411 wv := c.getWebViewLocked(parentPaneID)457 wv := c.getWebViewLocked(parentPaneID)
@@ -413,7 +459,38 @@ func (c *Coordinator) refreshPaneAfterOAuth(
413 log.Debug().459 log.Debug().
414 Str("parent_pane_id", string(parentPaneID)).460 Str("parent_pane_id", string(parentPaneID)).
415 Uint64("popup_id", uint64(popupID)).461 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+ )
417 return494 return
418 }495 }
419 496
@@ -429,5 +506,38 @@ func (c *Coordinator) refreshPaneAfterOAuth(
429 log.Info().506 log.Info().
430 Str("parent_pane_id", string(parentPaneID)).507 Str("parent_pane_id", string(parentPaneID)).
431 Uint64("popup_id", uint64(popupID)).508 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
433 }543 }
M internal/ui/coordinator/content/oauth_test.go +181 -24
@@ -6,11 +6,66 @@ import (
6 "time"6 "time"
7 7
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
9 11
10 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
12 )15 )
13 16
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+
14 func TestComposeOnClose_Order(t *testing.T) {69 func TestComposeOnClose_Order(t *testing.T) {
15 var calls []string70 var calls []string
16 71
@@ -47,52 +102,154 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
47 assert.Equal(t, []string{"existing", "next"}, calls)102 assert.Equal(t, []string{"existing", "next"}, calls)
48 }103 }
49 104
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) {
51 parentPaneID := entity.PaneID("parent-pane")147 parentPaneID := entity.PaneID("parent-pane")
52 popupID := port.WebViewID(101)148 popupID := port.WebViewID(101)
53 149
54 c := &Coordinator{150 c := &Coordinator{
55- webViews: make(map[entity.PaneID]port.WebView),151+ webViews: make(map[entity.PaneID]port.WebView),
56- popupOAuth: make(map[port.WebViewID]*popupOAuthState),152+ popups: newPopupManager(),
57- popupRefresh: make(map[entity.PaneID]*time.Timer),
58 }153 }
59 154
60- c.trackOAuthPopup(popupID, parentPaneID)155+ c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")
61- c.capturePopupOAuthState(popupID, "https://app/callback?code=123")156+ c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")
62 c.handlePopupOAuthClose(context.Background(), popupID)157 c.handlePopupOAuthClose(context.Background(), popupID)
63 158
64- c.popupMu.RLock()159+ c.popups.mu.RLock()
65- _, exists := c.popupOAuth[popupID]160+ _, exists := c.popups.popupOAuth[popupID]
66- refreshTimer := c.popupRefresh[parentPaneID]161+ refreshTimer := c.popups.popupRefresh[parentPaneID]
67- c.popupMu.RUnlock()162+ c.popups.mu.RUnlock()
68 163
69 assert.False(t, exists, "oauth state should be removed after close handling")164 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")
71 166
72 waitFor(t, time.Second, func() bool {167 waitFor(t, time.Second, func() bool {
73- c.popupMu.RLock()168+ c.popups.mu.RLock()
74- defer c.popupMu.RUnlock()169+ defer c.popups.mu.RUnlock()
75- return c.popupRefresh[parentPaneID] == nil170+ return c.popups.popupRefresh[parentPaneID] == nil
76 })171 })
77 }172 }
78 173
79-func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {174+func TestResumeParentPaneAfterOAuth_UnchangedParentDefersSameSiteFallbackReload(t *testing.T) {
80 parentPaneID := entity.PaneID("parent-pane")175 parentPaneID := entity.PaneID("parent-pane")
81 popupID := port.WebViewID(102)176 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()
82 180
83 c := &Coordinator{181 c := &Coordinator{
84- webViews: make(map[entity.PaneID]port.WebView),182+ webViews: map[entity.PaneID]port.WebView{
85- popupOAuth: make(map[port.WebViewID]*popupOAuthState),183+ parentPaneID: parentWV,
86- popupRefresh: make(map[entity.PaneID]*time.Timer),184+ },
185+ popups: newPopupManager(),
87 }186 }
88 187
89- c.trackOAuthPopup(popupID, parentPaneID)188+ c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123")
90- c.capturePopupOAuthState(popupID, "https://app/callback?error=access_denied")189+
91- c.handlePopupOAuthClose(context.Background(), popupID)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+ }
92 251
93- c.popupMu.RLock()252+ c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://accounts.example.com/callback?code=123")
94- defer c.popupMu.RUnlock()
95- assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")
96 }253 }
97 254
98 func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {255 func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {
M internal/ui/coordinator/content/popup.go +8 -469
@@ -2,7 +2,6 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"
6 "time"5 "time"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
@@ -92,69 +91,6 @@ func isReusableNamedPopupFrame(frameName string) bool {
92 return frameName != "" && frameName != "_blank"91 return frameName != "" && frameName != "_blank"
93 }92 }
94 93
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-
158 // InsertPopupInput contains the data needed to insert a popup into the workspace.94 // InsertPopupInput contains the data needed to insert a popup into the workspace.
159 type InsertPopupInput struct {95 type InsertPopupInput struct {
160 // ParentPaneID is the pane that spawned this popup.96 // ParentPaneID is the pane that spawned this popup.
@@ -213,19 +149,17 @@ func (c *Coordinator) SetPopupConfig(
213 popupConfig *entity.PopupBehaviorConfig,149 popupConfig *entity.PopupBehaviorConfig,
214 generateID func() string,150 generateID func() string,
215 ) {151 ) {
216- c.factory = factory152+ c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
217- c.popupConfig = popupConfig
218- c.generateID = generateID
219 }153 }
220 154
221 // SetOnInsertPopup sets the callback to insert popups into the workspace.155 // SetOnInsertPopup sets the callback to insert popups into the workspace.
222 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {156 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
223- c.onInsertPopup = fn157+ c.ensurePopupManager().setOnInsertPopup(fn)
224 }158 }
225 159
226 // SetOnClosePane sets the callback to close a pane when its popup closes.160 // SetOnClosePane sets the callback to close a pane when its popup closes.
227 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {161 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
228- c.onClosePane = fn162+ c.ensurePopupManager().setOnClosePane(fn)
229 }163 }
230 164
231 // buildPopupCreateHandler returns the OnCreate callback for a WebView.165 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
@@ -245,33 +179,11 @@ func (c *Coordinator) buildPopupCreateHandler(
245 }179 }
246 }180 }
247 181
248-// createPopupPane creates a new pane entity for a popup window.182+// handlePopupCreate handles a popup request from the current WebView.
249-func (c *Coordinator) createPopupPane(183+// Returns a WebView if popup handling is allowed, nil to block.
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.
272 //184 //
273 // IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this185 // 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.
275 // The WebView stays hidden until ready-to-show signal is received.187 // The WebView stays hidden until ready-to-show signal is received.
276 func (c *Coordinator) handlePopupCreate(188 func (c *Coordinator) handlePopupCreate(
277 ctx context.Context,189 ctx context.Context,
@@ -279,384 +191,11 @@ func (c *Coordinator) handlePopupCreate(
279 parentWV port.WebView,191 parentWV port.WebView,
280 req port.PopupRequest,192 req port.PopupRequest,
281 ) port.WebView {193 ) port.WebView {
282- log := logging.FromContext(ctx)194+ return c.ensurePopupManager().handlePopupCreate(ctx, c.popupHooks(), parentPaneID, parentWV, req)
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")
569 }195 }
570 196
571 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.197 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
572 // Opens the link in a new pane using blank_target_behavior config.198 // Opens the link in a new pane using blank_target_behavior config.
573 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {199 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
574- log := logging.FromContext(ctx)200+ return c.ensurePopupManager().handleLinkMiddleClick(ctx, c.popupHooks(), parentPaneID, uri)
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
662 }201 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 8
@@ -12,6 +13,7 @@ import (
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/port/mocks"14 "github.com/bnema/dumber/internal/application/port/mocks"
14 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
15 )17 )
16 18
17 // ---------------------------------------------------------------------------19 // ---------------------------------------------------------------------------
@@ -125,6 +127,321 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
125 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))127 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
126 }128 }
127 129
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+
128 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {445 func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
129 ctx := context.Background()446 ctx := context.Background()
130 parentPaneID := entity.PaneID("parent-pane")447 parentPaneID := entity.PaneID("parent-pane")
@@ -153,9 +470,8 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
153 470
154 insertCalls := 0471 insertCalls := 0
155 c := &Coordinator{472 c := &Coordinator{
156- webViews: make(map[entity.PaneID]port.WebView),473+ webViews: make(map[entity.PaneID]port.WebView),
157- pendingPopups: make(map[port.WebViewID]*PendingPopup),474+ popups: newPopupManager(),
158- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
159 }475 }
160 c.SetPopupConfig(factory, nil, nil)476 c.SetPopupConfig(factory, nil, nil)
161 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {477 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
@@ -189,22 +505,23 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
189 t.Parallel()505 t.Parallel()
190 506
191 c := &Coordinator{507 c := &Coordinator{
192- webViews: make(map[entity.PaneID]port.WebView),508+ webViews: make(map[entity.PaneID]port.WebView),
193- popupOAuth: make(map[port.WebViewID]*popupOAuthState),509+ popups: newPopupManager(),
194 }510 }
195 511
196 popupID := port.WebViewID(1)512 popupID := port.WebViewID(1)
197 parentPaneID := entity.PaneID("parent-pane")513 parentPaneID := entity.PaneID("parent-pane")
198 514
199- c.trackOAuthPopup(popupID, parentPaneID)515+ c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
200 516
201- c.popupMu.RLock()517+ c.popups.mu.RLock()
202- state, ok := c.popupOAuth[popupID]518+ state, ok := c.popups.popupOAuth[popupID]
203- c.popupMu.RUnlock()519+ c.popups.mu.RUnlock()
204 520
205 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")521 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
206 require.NotNil(t, state)522 require.NotNil(t, state)
207 assert.Equal(t, parentPaneID, state.ParentPaneID)523 assert.Equal(t, parentPaneID, state.ParentPaneID)
524+ assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
208 assert.False(t, state.Seen, "Seen should be false before capturing callback URI")525 assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
209 }526 }
210 527
@@ -212,17 +529,17 @@ func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
212 t.Parallel()529 t.Parallel()
213 530
214 c := &Coordinator{531 c := &Coordinator{
215- webViews: make(map[entity.PaneID]port.WebView),532+ webViews: make(map[entity.PaneID]port.WebView),
216- popupOAuth: make(map[port.WebViewID]*popupOAuthState),533+ popups: newPopupManager(),
217 }534 }
218 535
219 popupID := port.WebViewID(2)536 popupID := port.WebViewID(2)
220- c.trackOAuthPopup(popupID, entity.PaneID("parent"))537+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
221 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")538 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
222 539
223- c.popupMu.RLock()540+ c.popups.mu.RLock()
224- state := c.popupOAuth[popupID]541+ state := c.popups.popupOAuth[popupID]
225- c.popupMu.RUnlock()542+ c.popups.mu.RUnlock()
226 543
227 require.NotNil(t, state)544 require.NotNil(t, state)
228 assert.True(t, state.Seen)545 assert.True(t, state.Seen)
@@ -235,17 +552,17 @@ func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
235 t.Parallel()552 t.Parallel()
236 553
237 c := &Coordinator{554 c := &Coordinator{
238- webViews: make(map[entity.PaneID]port.WebView),555+ webViews: make(map[entity.PaneID]port.WebView),
239- popupOAuth: make(map[port.WebViewID]*popupOAuthState),556+ popups: newPopupManager(),
240 }557 }
241 558
242 popupID := port.WebViewID(3)559 popupID := port.WebViewID(3)
243- c.trackOAuthPopup(popupID, entity.PaneID("parent"))560+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
244 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")561 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
245 562
246- c.popupMu.RLock()563+ c.popups.mu.RLock()
247- state := c.popupOAuth[popupID]564+ state := c.popups.popupOAuth[popupID]
248- c.popupMu.RUnlock()565+ c.popups.mu.RUnlock()
249 566
250 require.NotNil(t, state)567 require.NotNil(t, state)
251 assert.True(t, state.Seen)568 assert.True(t, state.Seen)
@@ -257,16 +574,16 @@ func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
257 t.Parallel()574 t.Parallel()
258 575
259 c := &Coordinator{576 c := &Coordinator{
260- webViews: make(map[entity.PaneID]port.WebView),577+ webViews: make(map[entity.PaneID]port.WebView),
261- popupOAuth: make(map[port.WebViewID]*popupOAuthState),578+ popups: newPopupManager(),
262 }579 }
263 580
264 // Should not panic when no tracking entry exists.581 // Should not panic when no tracking entry exists.
265 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")582 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
266 583
267- c.popupMu.RLock()584+ c.popups.mu.RLock()
268- _, ok := c.popupOAuth[port.WebViewID(999)]585+ _, ok := c.popups.popupOAuth[port.WebViewID(999)]
269- c.popupMu.RUnlock()586+ c.popups.mu.RUnlock()
270 587
271 assert.False(t, ok)588 assert.False(t, ok)
272 }589 }
@@ -286,10 +603,10 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
286 603
287 c.SetPopupConfig(factory, cfg, genID)604 c.SetPopupConfig(factory, cfg, genID)
288 605
289- assert.Equal(t, factory, c.factory)606+ assert.Equal(t, factory, c.popups.factory)
290- assert.Equal(t, cfg, c.popupConfig)607+ assert.Equal(t, cfg, c.popups.popupConfig)
291- assert.NotNil(t, c.generateID)608+ assert.NotNil(t, c.popups.generatePaneID)
292- assert.Equal(t, "test-id", c.generateID())609+ assert.Equal(t, "test-id", c.popups.generatePaneID())
293 }610 }
294 611
295 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {612 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
@@ -298,10 +615,39 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
298 c := &Coordinator{}615 c := &Coordinator{}
299 c.SetPopupConfig(nil, nil, nil)616 c.SetPopupConfig(nil, nil, nil)
300 617
301- assert.Nil(t, c.factory)618+ assert.Nil(t, c.popups.factory)
302- assert.Nil(t, c.popupConfig)619+ assert.Nil(t, c.popups.popupConfig)
303- assert.Nil(t, c.generateID)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)) {
304 }649 }
650+func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
305 651
306 // stubFactory satisfies port.WebViewFactory without importing the mocks package.652 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
307 type stubFactory struct{}653 type stubFactory struct{}
@@ -322,7 +668,7 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
322 t.Parallel()668 t.Parallel()
323 669
324 c := &Coordinator{670 c := &Coordinator{
325- pendingPopups: make(map[port.WebViewID]*PendingPopup),671+ popups: newPopupManager(),
326 }672 }
327 673
328 const workers = 20674 const workers = 20
@@ -334,12 +680,12 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
334 go func(id int) {680 go func(id int) {
335 defer wg.Done()681 defer wg.Done()
336 popupID := port.WebViewID(id)682 popupID := port.WebViewID(id)
337- c.popupMu.Lock()683+ c.popups.mu.Lock()
338- c.pendingPopups[popupID] = &PendingPopup{684+ c.popups.pendingPopups[popupID] = &PendingPopup{
339 FrameName: "_blank",685 FrameName: "_blank",
340 PopupType: PopupTypeTab,686 PopupType: PopupTypeTab,
341 }687 }
342- c.popupMu.Unlock()688+ c.popups.mu.Unlock()
343 }(i)689 }(i)
344 }690 }
345 691
@@ -348,17 +694,17 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
348 go func(id int) {694 go func(id int) {
349 defer wg.Done()695 defer wg.Done()
350 popupID := port.WebViewID(id)696 popupID := port.WebViewID(id)
351- c.popupMu.RLock()697+ c.popups.mu.RLock()
352- _ = c.pendingPopups[popupID]698+ _ = c.popups.pendingPopups[popupID]
353- c.popupMu.RUnlock()699+ c.popups.mu.RUnlock()
354 }(i)700 }(i)
355 }701 }
356 702
357 wg.Wait()703 wg.Wait()
358 704
359- c.popupMu.RLock()705+ c.popups.mu.RLock()
360- count := len(c.pendingPopups)706+ count := len(c.popups.pendingPopups)
361- c.popupMu.RUnlock()707+ c.popups.mu.RUnlock()
362 708
363 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")709 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
364 }710 }
@@ -367,12 +713,12 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
367 t.Parallel()713 t.Parallel()
368 714
369 c := &Coordinator{715 c := &Coordinator{
370- pendingPopups: make(map[port.WebViewID]*PendingPopup),716+ popups: newPopupManager(),
371 }717 }
372 718
373 // Pre-populate719 // Pre-populate
374 for i := 0; i < 10; i++ {720 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}
376 }722 }
377 723
378 var wg sync.WaitGroup724 var wg sync.WaitGroup
@@ -382,9 +728,9 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
382 for i := 0; i < 10; i++ {728 for i := 0; i < 10; i++ {
383 go func(id int) {729 go func(id int) {
384 defer wg.Done()730 defer wg.Done()
385- c.popupMu.Lock()731+ c.popups.mu.Lock()
386- delete(c.pendingPopups, port.WebViewID(id))732+ delete(c.popups.pendingPopups, port.WebViewID(id))
387- c.popupMu.Unlock()733+ c.popups.mu.Unlock()
388 }(i)734 }(i)
389 }735 }
390 736
@@ -392,15 +738,15 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
392 for i := 0; i < 10; i++ {738 for i := 0; i < 10; i++ {
393 go func(id int) {739 go func(id int) {
394 defer wg.Done()740 defer wg.Done()
395- c.popupMu.RLock()741+ c.popups.mu.RLock()
396- _ = c.pendingPopups[port.WebViewID(id)]742+ _ = c.popups.pendingPopups[port.WebViewID(id)]
397- c.popupMu.RUnlock()743+ c.popups.mu.RUnlock()
398 }(i)744 }(i)
399 }745 }
400 746
401 wg.Wait()747 wg.Wait()
402 748
403- c.popupMu.RLock()749+ c.popups.mu.RLock()
404- defer c.popupMu.RUnlock()750+ defer c.popups.mu.RUnlock()
405- assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")751+ assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
406 }752 }

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