Merge pull request #231 from bnema/fix/cef-popup-pane-handling
fix: improve CEF popup handling and OAuth reliability
35 files · +5854 -1378
M .mockery.yaml +2 -1
| @@ -42,7 +42,8 @@ packages: | |||
| 42 | 42 | FilterApplier: {} | |
| 43 | 43 | FaviconDatabase: {} | |
| 44 | 44 | IdleInhibitor: {} | |
| 45 | - | PopupCapable: {} | |
| 45 | + | PopupLifecycleCapable: {} | |
| 46 | + | PopupOpenerCapable: {} | |
| 46 | 47 | OAuthCallbackCapable: {} | |
| 47 | 48 | DevToolsOpener: {} | |
| 48 | 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 | 14273 | 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 | 14277 | // The first argument is typically a *testing.T value. | |
| 14278 | - | func NewMockPopupCapable(t interface { | |
| 14278 | + | func NewMockPopupLifecycleCapable(t interface { | |
| 14279 | 14279 | mock.TestingT | |
| 14280 | 14280 | Cleanup(func()) | |
| 14281 | - | }) *MockPopupCapable { | |
| 14282 | - | mock := &MockPopupCapable{} | |
| 14281 | + | }) *MockPopupLifecycleCapable { | |
| 14282 | + | mock := &MockPopupLifecycleCapable{} | |
| 14283 | 14283 | mock.Mock.Test(t) | |
| 14284 | 14284 | ||
| 14285 | 14285 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -14287,37 +14287,77 @@ func NewMockPopupCapable(t interface { | |||
| 14287 | 14287 | return mock | |
| 14288 | 14288 | } | |
| 14289 | 14289 | ||
| 14290 | - | // MockPopupCapable is an autogenerated mock type for the PopupCapable type | |
| 14291 | - | type MockPopupCapable struct { | |
| 14290 | + | // MockPopupLifecycleCapable is an autogenerated mock type for the PopupLifecycleCapable type | |
| 14291 | + | type MockPopupLifecycleCapable struct { | |
| 14292 | 14292 | mock.Mock | |
| 14293 | 14293 | } | |
| 14294 | 14294 | ||
| 14295 | - | type MockPopupCapable_Expecter struct { | |
| 14295 | + | type MockPopupLifecycleCapable_Expecter struct { | |
| 14296 | 14296 | mock *mock.Mock | |
| 14297 | 14297 | } | |
| 14298 | 14298 | ||
| 14299 | - | func (_m *MockPopupCapable) EXPECT() *MockPopupCapable_Expecter { | |
| 14300 | - | return &MockPopupCapable_Expecter{mock: &_m.Mock} | |
| 14299 | + | func (_m *MockPopupLifecycleCapable) EXPECT() *MockPopupLifecycleCapable_Expecter { | |
| 14300 | + | return &MockPopupLifecycleCapable_Expecter{mock: &_m.Mock} | |
| 14301 | 14301 | } | |
| 14302 | 14302 | ||
| 14303 | - | // SetOnClose provides a mock function for the type MockPopupCapable | |
| 14304 | - | func (_mock *MockPopupCapable) SetOnClose(fn func()) { | |
| 14303 | + | // PrimePopupNavigation provides a mock function for the type MockPopupLifecycleCapable | |
| 14304 | + | func (_mock *MockPopupLifecycleCapable) PrimePopupNavigation(uri string) { | |
| 14305 | + | _mock.Called(uri) | |
| 14306 | + | return | |
| 14307 | + | } | |
| 14308 | + | ||
| 14309 | + | // MockPopupLifecycleCapable_PrimePopupNavigation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrimePopupNavigation' | |
| 14310 | + | type MockPopupLifecycleCapable_PrimePopupNavigation_Call struct { | |
| 14311 | + | *mock.Call | |
| 14312 | + | } | |
| 14313 | + | ||
| 14314 | + | // PrimePopupNavigation is a helper method to define mock.On call | |
| 14315 | + | // - uri string | |
| 14316 | + | func (_e *MockPopupLifecycleCapable_Expecter) PrimePopupNavigation(uri interface{}) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14317 | + | return &MockPopupLifecycleCapable_PrimePopupNavigation_Call{Call: _e.mock.On("PrimePopupNavigation", uri)} | |
| 14318 | + | } | |
| 14319 | + | ||
| 14320 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Run(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14321 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14322 | + | var arg0 string | |
| 14323 | + | if args[0] != nil { | |
| 14324 | + | arg0 = args[0].(string) | |
| 14325 | + | } | |
| 14326 | + | run( | |
| 14327 | + | arg0, | |
| 14328 | + | ) | |
| 14329 | + | }) | |
| 14330 | + | return _c | |
| 14331 | + | } | |
| 14332 | + | ||
| 14333 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Return() *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14334 | + | _c.Call.Return() | |
| 14335 | + | return _c | |
| 14336 | + | } | |
| 14337 | + | ||
| 14338 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) RunAndReturn(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14339 | + | _c.Run(run) | |
| 14340 | + | return _c | |
| 14341 | + | } | |
| 14342 | + | ||
| 14343 | + | // SetOnClose provides a mock function for the type MockPopupLifecycleCapable | |
| 14344 | + | func (_mock *MockPopupLifecycleCapable) SetOnClose(fn func()) { | |
| 14305 | 14345 | _mock.Called(fn) | |
| 14306 | 14346 | 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' | |
| 14310 | - | type MockPopupCapable_SetOnClose_Call struct { | |
| 14349 | + | // MockPopupLifecycleCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose' | |
| 14350 | + | type MockPopupLifecycleCapable_SetOnClose_Call struct { | |
| 14311 | 14351 | *mock.Call | |
| 14312 | 14352 | } | |
| 14313 | 14353 | ||
| 14314 | 14354 | // SetOnClose is a helper method to define mock.On call | |
| 14315 | 14355 | // - fn func() | |
| 14316 | - | func (_e *MockPopupCapable_Expecter) SetOnClose(fn interface{}) *MockPopupCapable_SetOnClose_Call { | |
| 14317 | - | return &MockPopupCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)} | |
| 14356 | + | func (_e *MockPopupLifecycleCapable_Expecter) SetOnClose(fn interface{}) *MockPopupLifecycleCapable_SetOnClose_Call { | |
| 14357 | + | return &MockPopupLifecycleCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)} | |
| 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 | 14361 | _c.Call.Run(func(args mock.Arguments) { | |
| 14322 | 14362 | var arg0 func() | |
| 14323 | 14363 | if args[0] != nil { | |
| @@ -14330,34 +14370,34 @@ func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupC | |||
| 14330 | 14370 | 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 | 14374 | _c.Call.Return() | |
| 14335 | 14375 | 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 | 14379 | _c.Run(run) | |
| 14340 | 14380 | return _c | |
| 14341 | 14381 | } | |
| 14342 | 14382 | ||
| 14343 | - | // SetOnReadyToShow provides a mock function for the type MockPopupCapable | |
| 14344 | - | func (_mock *MockPopupCapable) SetOnReadyToShow(fn func()) { | |
| 14383 | + | // SetOnReadyToShow provides a mock function for the type MockPopupLifecycleCapable | |
| 14384 | + | func (_mock *MockPopupLifecycleCapable) SetOnReadyToShow(fn func()) { | |
| 14345 | 14385 | _mock.Called(fn) | |
| 14346 | 14386 | 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' | |
| 14350 | - | type MockPopupCapable_SetOnReadyToShow_Call struct { | |
| 14389 | + | // MockPopupLifecycleCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow' | |
| 14390 | + | type MockPopupLifecycleCapable_SetOnReadyToShow_Call struct { | |
| 14351 | 14391 | *mock.Call | |
| 14352 | 14392 | } | |
| 14353 | 14393 | ||
| 14354 | 14394 | // SetOnReadyToShow is a helper method to define mock.On call | |
| 14355 | 14395 | // - fn func() | |
| 14356 | - | func (_e *MockPopupCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupCapable_SetOnReadyToShow_Call { | |
| 14357 | - | return &MockPopupCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)} | |
| 14396 | + | func (_e *MockPopupLifecycleCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupLifecycleCapable_SetOnReadyToShow_Call { | |
| 14397 | + | return &MockPopupLifecycleCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)} | |
| 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 | 14401 | _c.Call.Run(func(args mock.Arguments) { | |
| 14362 | 14402 | var arg0 func() | |
| 14363 | 14403 | if args[0] != nil { | |
| @@ -14370,49 +14410,246 @@ func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *Mock | |||
| 14370 | 14410 | 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 | 14414 | _c.Call.Return() | |
| 14375 | 14415 | 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 | 14419 | _c.Run(run) | |
| 14380 | 14420 | return _c | |
| 14381 | 14421 | } | |
| 14382 | 14422 | ||
| 14383 | - | // Show provides a mock function for the type MockPopupCapable | |
| 14384 | - | func (_mock *MockPopupCapable) Show() { | |
| 14423 | + | // Show provides a mock function for the type MockPopupLifecycleCapable | |
| 14424 | + | func (_mock *MockPopupLifecycleCapable) Show() { | |
| 14385 | 14425 | _mock.Called() | |
| 14386 | 14426 | 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' | |
| 14390 | - | type MockPopupCapable_Show_Call struct { | |
| 14429 | + | // MockPopupLifecycleCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show' | |
| 14430 | + | type MockPopupLifecycleCapable_Show_Call struct { | |
| 14391 | 14431 | *mock.Call | |
| 14392 | 14432 | } | |
| 14393 | 14433 | ||
| 14394 | 14434 | // Show is a helper method to define mock.On call | |
| 14395 | - | func (_e *MockPopupCapable_Expecter) Show() *MockPopupCapable_Show_Call { | |
| 14396 | - | return &MockPopupCapable_Show_Call{Call: _e.mock.On("Show")} | |
| 14435 | + | func (_e *MockPopupLifecycleCapable_Expecter) Show() *MockPopupLifecycleCapable_Show_Call { | |
| 14436 | + | return &MockPopupLifecycleCapable_Show_Call{Call: _e.mock.On("Show")} | |
| 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 | 14440 | _c.Call.Run(func(args mock.Arguments) { | |
| 14401 | 14441 | run() | |
| 14402 | 14442 | }) | |
| 14403 | 14443 | 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 | 14514 | _c.Call.Return() | |
| 14408 | 14515 | 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 | 14519 | _c.Run(run) | |
| 14413 | 14520 | 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 | 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 | 14654 | // The first argument is typically a *testing.T value. | |
| 14418 | 14655 | func NewMockOAuthCallbackCapable(t interface { | |
M internal/application/port/webview.go +24 -12
| @@ -3,9 +3,7 @@ | |||
| 3 | 3 | // remain independent of specific implementations (WebKit, GTK, etc.). | |
| 4 | 4 | package port | |
| 5 | 5 | ||
| 6 | - | import ( | |
| 7 | - | "context" | |
| 8 | - | ) | |
| 6 | + | import "context" | |
| 9 | 7 | ||
| 10 | 8 | // WebViewID uniquely identifies a WebView instance. | |
| 11 | 9 | type WebViewID uint64 | |
| @@ -64,10 +62,11 @@ const ( | |||
| 64 | 62 | ||
| 65 | 63 | // PopupRequest contains metadata about a popup window request. | |
| 66 | 64 | type PopupRequest struct { | |
| 67 | - | TargetURI string | |
| 68 | - | FrameName string // e.g., "_blank", custom name, or empty | |
| 69 | - | IsUserGesture bool | |
| 70 | - | ParentViewID WebViewID | |
| 65 | + | TargetURI string | |
| 66 | + | FrameName string // e.g., "_blank", custom name, or empty | |
| 67 | + | IsUserGesture bool | |
| 68 | + | NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access | |
| 69 | + | ParentViewID WebViewID | |
| 71 | 70 | } | |
| 72 | 71 | ||
| 73 | 72 | // Texture represents a graphics texture returned by the engine. | |
| @@ -314,14 +313,27 @@ type Printer interface { | |||
| 314 | 313 | PrintPage() | |
| 315 | 314 | } | |
| 316 | 315 | ||
| 317 | - | // PopupCapable is implemented by WebViews that support popup lifecycle callbacks. | |
| 318 | - | // SetOnClose composes the provided function with any existing close handler so | |
| 319 | - | // that multiple callers can each register a close callback without overwriting | |
| 320 | - | // one another. | |
| 321 | - | type PopupCapable interface { | |
| 316 | + | // PopupLifecycleCapable is implemented by WebViews that support the full popup | |
| 317 | + | // pane lifecycle. SetOnClose composes the provided function with any existing | |
| 318 | + | // close handler so multiple callers can register close hooks without | |
| 319 | + | // overwriting one another. PrimePopupNavigation lets engines preserve the popup | |
| 320 | + | // target URI before the browser is fully created. | |
| 321 | + | type PopupLifecycleCapable interface { | |
| 322 | 322 | SetOnReadyToShow(fn func()) | |
| 323 | 323 | SetOnClose(fn func()) | |
| 324 | 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 | 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 | 45 | type dumberApp struct { | |
| 46 | 46 | engine *Engine | |
| 47 | 47 | bph purecef.BrowserProcessHandler | |
| 48 | + | rph purecef.RenderProcessHandler | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | 51 | // newDumberApp creates an App whose BrowserProcessHandler handles context | |
| @@ -53,15 +54,16 @@ type dumberApp struct { | |||
| 53 | 54 | func newDumberApp(engine *Engine) purecef.App { | |
| 54 | 55 | app := &dumberApp{engine: engine} | |
| 55 | 56 | app.bph = &dumberBPH{engine: engine} | |
| 57 | + | app.rph = newPopupOpenerRenderProcessHandler() | |
| 56 | 58 | return app | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | // NewSubprocessApp returns a lightweight raw App implementation for CEF | |
| 60 | - | // subprocesses re-executed from the main dumber binary. Keep subprocesses free | |
| 61 | - | // of custom render handlers for now: the renderer bridge introduced a startup | |
| 62 | - | // regression where OSR pages stopped producing their first paint. | |
| 62 | + | // subprocesses re-executed from the main dumber binary. Keep the legacy | |
| 63 | + | // renderer bridge disabled, but allow the minimal popup-opener render handler | |
| 64 | + | // needed to install synthetic opener state before page scripts run. | |
| 63 | 65 | func NewSubprocessApp() purecef.App { | |
| 64 | - | return &subprocessApp{} | |
| 66 | + | return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()} | |
| 65 | 67 | } | |
| 66 | 68 | ||
| 67 | 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 | 94 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 93 | 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 | 100 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | |
| 99 | 101 | configureCommandLine(commandLine) | |
| @@ -103,7 +105,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra | |||
| 103 | 105 | } | |
| 104 | 106 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 105 | 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 | 110 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | |
| 109 | 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 | 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 | 388 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 625 | 389 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 626 | 390 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -741,9 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 741 | 505 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 742 | 506 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 743 | 507 | ||
| 744 | - | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 745 | - | // mode while the native renderer bridge remains disabled. | |
| 746 | - | bridgeNonce := wv.rotateBridgeNonce() | |
| 508 | + | // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still | |
| 509 | + | // need a JS bridge in OSR mode while the native renderer bridge remains disabled. | |
| 510 | + | bridgeNonce := wv.ensureBridgeNonce() | |
| 747 | 511 | if bridgeNonce == "" { | |
| 748 | 512 | ctx := context.Background() | |
| 749 | 513 | if ci != nil && ci.engine != nil { | |
| @@ -751,7 +515,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 751 | 515 | } | |
| 752 | 516 | logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed") | |
| 753 | 517 | } else { | |
| 754 | - | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce)) | |
| 518 | + | wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce)) | |
| 755 | 519 | } | |
| 756 | 520 | ||
| 757 | 521 | // Video playback diagnostic — logs video element state changes. | |
M internal/infrastructure/cef/content_injector_test.go +40 -6
| @@ -1,17 +1,18 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | func testClipboardSelectionFetchBridgeJS() string { | |
| 10 | - | return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce") | |
| 10 | + | func testTrustedPageFetchBridgeJS() string { | |
| 11 | + | return buildTrustedPageFetchBridgeJS("test-bridge-nonce") | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) { | |
| 14 | - | script := testClipboardSelectionFetchBridgeJS() | |
| 15 | + | script := testTrustedPageFetchBridgeJS() | |
| 15 | 16 | ||
| 16 | 17 | require.Contains(t, script, "document.activeElement") | |
| 17 | 18 | require.Contains(t, script, "selectionStart") | |
| @@ -21,7 +22,7 @@ func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *t | |||
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) { | |
| 24 | - | script := testClipboardSelectionFetchBridgeJS() | |
| 25 | + | script := testTrustedPageFetchBridgeJS() | |
| 25 | 26 | ||
| 26 | 27 | require.Contains(t, script, "dumb:///api/focus-sync") | |
| 27 | 28 | require.Contains(t, script, "X-Dumber-Bridge-Action") | |
| @@ -36,7 +37,7 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin | |||
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) { | |
| 39 | - | script := testClipboardSelectionFetchBridgeJS() | |
| 40 | + | script := testTrustedPageFetchBridgeJS() | |
| 40 | 41 | ||
| 41 | 42 | require.Contains(t, script, "dumb:///api/clipboard-set") | |
| 42 | 43 | require.Contains(t, script, "X-Dumber-Body") | |
| @@ -47,7 +48,7 @@ func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testin | |||
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) { | |
| 50 | - | script := testClipboardSelectionFetchBridgeJS() | |
| 51 | + | script := testTrustedPageFetchBridgeJS() | |
| 51 | 52 | ||
| 52 | 53 | require.Contains(t, script, "navigator && navigator.clipboard") | |
| 53 | 54 | require.Contains(t, script, "clipboardProto.writeText") | |
| @@ -61,3 +62,36 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) | |||
| 61 | 62 | require.NotContains(t, script, "setBridgeNonce") | |
| 62 | 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 | 49 | alreadyRunningAppRelaunchHandler func(string) | |
| 50 | 50 | ||
| 51 | 51 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 52 | - | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 53 | - | activeCount atomic.Int32 | |
| 52 | + | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 53 | + | browserWebViews sync.Map // map[int32]*WebView | |
| 54 | + | activeCount atomic.Int32 | |
| 54 | 55 | ||
| 55 | 56 | // shutdownNotify is signaled when the active webview count reaches 0 | |
| 56 | 57 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| @@ -145,10 +146,46 @@ func (e *Engine) registerWebView(wv *WebView) { | |||
| 145 | 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 | 184 | // unregisterWebView removes a webview from the active tracking map. | |
| 149 | 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 | 187 | e.activeWebViews.Delete(wv.id) | |
| 188 | + | e.unbindBrowserWebView(browserID, wv) | |
| 152 | 189 | if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil { | |
| 153 | 190 | select { | |
| 154 | 191 | case e.shutdownNotify <- struct{}{}: | |
| @@ -432,28 +469,18 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri | |||
| 432 | 469 | return false | |
| 433 | 470 | } | |
| 434 | 471 | ||
| 435 | - | browserID := browser.GetIdentifier() | |
| 436 | - | valid := false | |
| 437 | - | e.activeWebViews.Range(func(_, value any) bool { | |
| 438 | - | wv, ok := value.(*WebView) | |
| 439 | - | if !ok || wv == nil { | |
| 440 | - | return true | |
| 441 | - | } | |
| 472 | + | wv := e.webViewForBrowser(browser) | |
| 473 | + | if wv == nil { | |
| 474 | + | return false | |
| 475 | + | } | |
| 442 | 476 | ||
| 443 | - | wv.mu.RLock() | |
| 444 | - | wvBrowser := wv.browser | |
| 445 | - | wvBridgeNonce := wv.bridgeNonce | |
| 446 | - | wv.mu.RUnlock() | |
| 447 | - | if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID { | |
| 448 | - | return true | |
| 449 | - | } | |
| 450 | - | if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 { | |
| 451 | - | valid = true | |
| 452 | - | return false | |
| 453 | - | } | |
| 454 | - | return true | |
| 455 | - | }) | |
| 456 | - | return valid | |
| 477 | + | wv.mu.RLock() | |
| 478 | + | wvBridgeNonce := wv.bridgeNonce | |
| 479 | + | wv.mu.RUnlock() | |
| 480 | + | if wvBridgeNonce == "" { | |
| 481 | + | return false | |
| 482 | + | } | |
| 483 | + | return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 | |
| 457 | 484 | } | |
| 458 | 485 | ||
| 459 | 486 | func (e *Engine) currentDownloadHandler() *downloadHandler { | |
M internal/infrastructure/cef/engine_init.go +100 -59
| @@ -179,51 +179,80 @@ func wireEngine( | |||
| 179 | 179 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | scale := detectHiDPIScale(logger) | |
| 183 | - | ||
| 184 | - | // Initialize the GPU transcoder when enabled in the loaded app config. | |
| 185 | - | var mediaTranscoder port.MediaTranscoder | |
| 186 | - | transcoderState := buildTranscoderStartupState(transcodingCfg) | |
| 187 | - | if transcodingCfg.Enabled { | |
| 188 | - | transcoderState.ProbeAttempted = true | |
| 189 | - | tc := transcoderpkg.New(config.TranscodingConfig{ | |
| 190 | - | Enabled: transcodingCfg.Enabled, | |
| 191 | - | HWAccel: transcodingCfg.HWAccel, | |
| 192 | - | MaxConcurrent: transcodingCfg.MaxConcurrent, | |
| 193 | - | Quality: transcodingCfg.Quality, | |
| 194 | - | }, logger) | |
| 195 | - | caps := tc.Capabilities() | |
| 196 | - | transcoderState.API = caps.API | |
| 197 | - | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |
| 198 | - | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |
| 199 | - | if tc.Available() { | |
| 200 | - | mediaTranscoder = tc | |
| 201 | - | transcoderState.Status = "available" | |
| 202 | - | logger.Info(). | |
| 203 | - | Str("api", tc.Capabilities().API). | |
| 204 | - | Strs("encoders", tc.Capabilities().Encoders). | |
| 205 | - | Msg("cef: GPU transcoding available") | |
| 206 | - | } else { | |
| 207 | - | transcoderState.Status = "unavailable_no_compatible_gpu" | |
| 208 | - | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | |
| 209 | - | } | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 213 | - | scale: scale, | |
| 182 | + | mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger) | |
| 183 | + | eng.gl = gl | |
| 184 | + | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 185 | + | scale: detectHiDPIScale(logger), | |
| 214 | 186 | windowlessFrameRate: windowlessFrameRate, | |
| 215 | 187 | transcoder: mediaTranscoder, | |
| 216 | 188 | mediaClassifier: mediaClassifier, | |
| 217 | 189 | audioOutputFactory: audioFactory, | |
| 218 | 190 | }) | |
| 219 | - | pool := newWebViewPool(factory) | |
| 220 | - | ||
| 221 | - | eng.gl = gl | |
| 222 | - | eng.factory = factory | |
| 223 | - | eng.pool = pool | |
| 191 | + | eng.pool = newWebViewPool(eng.factory) | |
| 224 | 192 | eng.contentInj = newContentInjector(eng, nil) | |
| 225 | 193 | 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 | 256 | messageRouter := NewMessageRouter(ctx) | |
| 228 | 257 | schemeHandler, err := newDumbSchemeHandler( | |
| 229 | 258 | ctx, | |
| @@ -233,15 +262,25 @@ func wireEngine( | |||
| 233 | 262 | defaultConfigPayload, | |
| 234 | 263 | ) | |
| 235 | 264 | if err != nil { | |
| 236 | - | purecef.Shutdown() | |
| 237 | - | return nil, err | |
| 265 | + | return nil, nil, err | |
| 238 | 266 | } | |
| 239 | 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 | 280 | // Bridge clipboard writes from page JS → GDK system clipboard. | |
| 242 | 281 | // The callback is invoked on the CEF IO thread, so schedule the actual GDK | |
| 243 | 282 | // write on the GTK main loop. | |
| 244 | - | schemeHandler.onClipboardSet = func(text string) { | |
| 283 | + | return func(text string) { | |
| 245 | 284 | fn := glib.SourceOnceFunc(func(_ uintptr) { | |
| 246 | 285 | if display := gdk.DisplayGetDefault(); display != nil { | |
| 247 | 286 | if cb := display.GetClipboard(); cb != nil { | |
| @@ -256,35 +295,37 @@ func wireEngine( | |||
| 256 | 295 | }) | |
| 257 | 296 | glib.IdleAddOnce(&fn, 0) | |
| 258 | 297 | } | |
| 298 | + | } | |
| 259 | 299 | ||
| 260 | - | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 261 | - | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |
| 262 | - | ||
| 263 | - | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 300 | + | func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) { | |
| 301 | + | registerEngineSchemeFactory(logger, "dumb", "", schemeFactory) | |
| 302 | + | registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory) | |
| 303 | + | } | |
| 264 | 304 | ||
| 265 | - | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | |
| 266 | - | if result != 1 { | |
| 267 | - | logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory") | |
| 268 | - | } else { | |
| 305 | + | func registerEngineSchemeFactory( | |
| 306 | + | logger *zerolog.Logger, | |
| 307 | + | scheme, host string, | |
| 308 | + | schemeFactory purecef.SchemeHandlerFactory, | |
| 309 | + | ) { | |
| 310 | + | result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory) | |
| 311 | + | if scheme == "dumb" && host == "" { | |
| 312 | + | if result != 1 { | |
| 313 | + | logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory") | |
| 314 | + | return | |
| 315 | + | } | |
| 269 | 316 | logger.Info().Msg("cef: registered dumb:// scheme handler factory") | |
| 317 | + | return | |
| 270 | 318 | } | |
| 271 | - | ||
| 272 | - | result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory) | |
| 273 | 319 | if result != 1 { | |
| 274 | 320 | logger.Error(). | |
| 275 | 321 | Int32("result", result). | |
| 276 | 322 | Str("origin", actualInternalOrigin). | |
| 277 | 323 | Msg("cef: failed to register internal https handler factory") | |
| 278 | - | } else { | |
| 279 | - | logger.Info(). | |
| 280 | - | Str("origin", actualInternalOrigin). | |
| 281 | - | Msg("cef: registered internal https handler factory") | |
| 324 | + | return | |
| 282 | 325 | } | |
| 283 | - | ||
| 284 | - | eng.messageRouter = messageRouter | |
| 285 | - | eng.schemeHandler = schemeHandler | |
| 286 | - | ||
| 287 | - | return eng, nil | |
| 326 | + | logger.Info(). | |
| 327 | + | Str("origin", actualInternalOrigin). | |
| 328 | + | Msg("cef: registered internal https handler factory") | |
| 288 | 329 | } | |
| 289 | 330 | ||
| 290 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "sync/atomic" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 10 | ||
| @@ -41,6 +43,13 @@ type resizeNotifiableBrowserHost interface { | |||
| 41 | 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 | 53 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 45 | 54 | // given GL loader and HiDPI scale factor. | |
| 46 | 55 | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | |
| @@ -72,17 +81,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |||
| 72 | 81 | // asynchronously; the returned WebView is usable immediately but navigation | |
| 73 | 82 | // will fail with errNoBrowser until OnAfterCreated fires. | |
| 74 | 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 | 111 | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) | |
| 78 | 112 | ||
| 79 | 113 | wv := &WebView{ | |
| 80 | - | id: id, | |
| 81 | - | ctx: ctx, | |
| 82 | - | engine: f.engine, | |
| 83 | - | pipeline: pipeline, | |
| 84 | - | audioOutputFactory: f.audioOutputFactory, | |
| 85 | - | resizeReconciler: newResizeReconciler(ctx, id), | |
| 114 | + | id: id, | |
| 115 | + | ctx: ctx, | |
| 116 | + | engine: f.engine, | |
| 117 | + | pipeline: pipeline, | |
| 118 | + | audioOutputFactory: f.audioOutputFactory, | |
| 119 | + | resizeReconciler: newResizeReconciler(ctx, id), | |
| 120 | + | windowlessFrameRate: f.windowlessFrameRate, | |
| 121 | + | backgroundColor: f.bgColor.Load(), | |
| 86 | 122 | } | |
| 87 | 123 | ||
| 88 | 124 | var transcodingHandler purecef.ResourceRequestHandler | |
| @@ -129,30 +165,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 129 | 165 | ||
| 130 | 166 | // Build a CEF client backed by our handlerSet. | |
| 131 | 167 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 132 | - | client := purecef.NewClient(wv.handlers) | |
| 133 | - | wv.client = client | |
| 134 | - | ||
| 135 | - | // Configure WindowInfo for off-screen rendering (OSR). | |
| 136 | - | windowInfo := purecef.NewWindowInfo() | |
| 137 | - | windowInfo.WindowlessRenderingEnabled = 1 | |
| 138 | - | if externalBeginFrameEnabled() { | |
| 139 | - | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Configure BrowserSettings. | |
| 143 | - | settings := purecef.NewBrowserSettings() | |
| 144 | - | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 145 | - | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 146 | - | if bg := f.bgColor.Load(); bg != 0 { | |
| 147 | - | settings.BackgroundColor = bg | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // Defer browser creation until the GL area has a non-zero size. | |
| 151 | - | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 152 | - | // is not yet realized at this point. | |
| 153 | - | f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings) | |
| 154 | - | ||
| 155 | - | return wv, nil | |
| 168 | + | wv.client = purecef.NewClient(wv.handlers) | |
| 169 | + | return wv | |
| 156 | 170 | } | |
| 157 | 171 | ||
| 158 | 172 | func (f *WebViewFactory) configureInitialBrowserCreation( | |
| @@ -166,60 +180,8 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 166 | 180 | } | |
| 167 | 181 | ||
| 168 | 182 | pipeline.onFirstResize = func(w, h int32) { | |
| 169 | - | log := logging.FromContext(ctx) | |
| 170 | - | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 171 | - | pc := wv.takePendingCreate() | |
| 172 | - | if pc == nil { | |
| 173 | - | return | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | task := purecef.NewTask(cefTaskFunc(func() { | |
| 177 | - | if wv.destroyed.Load() { | |
| 178 | - | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 179 | - | return | |
| 180 | - | } | |
| 181 | - | pendingURL := wv.pendingNavigationURI() | |
| 182 | - | // Always bootstrap OSR browsers on about:blank first. Starting directly on | |
| 183 | - | // the target URL can race host visibility/focus setup and strand the | |
| 184 | - | // renderer without an initial paint. We replay pending navigation from | |
| 185 | - | // OnAfterCreated once the host is fully wired. | |
| 186 | - | initialURL := "about:blank" | |
| 187 | - | result := purecef.BrowserHostCreateBrowser( | |
| 188 | - | pc.windowInfo, | |
| 189 | - | pc.client, | |
| 190 | - | initialURL, | |
| 191 | - | pc.settings, | |
| 192 | - | nil, // extraInfo | |
| 193 | - | nil, // requestContext | |
| 194 | - | ) | |
| 195 | - | log.Debug(). | |
| 196 | - | Str("initial_url", initialURL). | |
| 197 | - | Str("pending_url", pendingURL). | |
| 198 | - | Msg("cef: BrowserHostCreateBrowser initial URL") | |
| 199 | - | if f.engine != nil { | |
| 200 | - | f.engine.recordBrowserCreateRequest(w, h, result) | |
| 201 | - | } | |
| 202 | - | log.Debug(). | |
| 203 | - | Int32("result", result). | |
| 204 | - | Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled). | |
| 205 | - | Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate). | |
| 206 | - | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | |
| 207 | - | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | |
| 208 | - | Bool("client_nil", pc.client == nil). | |
| 209 | - | Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread") | |
| 210 | - | })) | |
| 211 | - | ||
| 212 | - | postResult := purecef.PostTask(purecef.ThreadIDTidUi, task) | |
| 213 | - | if postResult != 1 { | |
| 214 | - | wv.mu.Lock() | |
| 215 | - | if wv.pendingCreate == nil { | |
| 216 | - | wv.pendingCreate = pc | |
| 217 | - | } | |
| 218 | - | wv.mu.Unlock() | |
| 219 | - | log.Error(). | |
| 220 | - | Int32("result", postResult). | |
| 221 | - | Msg("cef: failed to post initial browser creation to CEF UI thread") | |
| 222 | - | } | |
| 183 | + | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 184 | + | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 223 | 185 | } | |
| 224 | 186 | ||
| 225 | 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 | 315 | func notifyBrowserResize(host resizeNotifiableBrowserHost) { | |
| 272 | 316 | if host == nil { | |
| 273 | 317 | return | |
| @@ -276,9 +320,60 @@ func notifyBrowserResize(host resizeNotifiableBrowserHost) { | |||
| 276 | 320 | host.Invalidate(purecef.PaintElementTypePetView) | |
| 277 | 321 | } | |
| 278 | 322 | ||
| 279 | - | // CreateRelated creates a WebView that shares session/cookies with the parent. | |
| 280 | - | // TODO(phase2): look up the parent browser by parentID and use the same | |
| 281 | - | // request context so cookies and session state are shared. | |
| 282 | - | func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) { | |
| 283 | - | return nil, ErrRelatedWebViewUnsupported | |
| 323 | + | // CreateRelated creates a popup shell for a native CEF popup. The popup | |
| 324 | + | // browser itself is created by CEF from OnBeforePopup and later bound to this | |
| 325 | + | // shell in OnAfterCreated. | |
| 326 | + | func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) { | |
| 327 | + | if f == nil || f.engine == nil { | |
| 328 | + | return nil, ErrRelatedWebViewUnsupported | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | parent := f.engine.lookupWebView(parentID) | |
| 332 | + | if parent == nil { | |
| 333 | + | return nil, fmt.Errorf("parent webview %d not found", parentID) | |
| 334 | + | } | |
| 335 | + | if parent.destroyed.Load() { | |
| 336 | + | return nil, fmt.Errorf("parent webview %d is destroyed", parentID) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | popupWV := f.newWebView(ctx) | |
| 340 | + | popupWV.markNativePopupCandidate(parent) | |
| 341 | + | ||
| 342 | + | windowInfo := purecef.NewWindowInfo() | |
| 343 | + | windowInfo.WindowlessRenderingEnabled = 1 | |
| 344 | + | if externalBeginFrameEnabled() { | |
| 345 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | settings := purecef.NewBrowserSettings() | |
| 349 | + | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 350 | + | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 351 | + | if bg := f.bgColor.Load(); bg != 0 { | |
| 352 | + | settings.BackgroundColor = bg | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings) | |
| 356 | + | popupWV.pipeline.onFirstResize = func(w, h int32) { | |
| 357 | + | log := logging.FromContext(ctx) | |
| 358 | + | if popupWV.awaitsNativePopupAttachment() { | |
| 359 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach") | |
| 360 | + | popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() { | |
| 361 | + | if !popupWV.startNativePopupFallback() { | |
| 362 | + | return | |
| 363 | + | } | |
| 364 | + | logging.FromContext(ctx).Warn(). | |
| 365 | + | Uint64("webview_id", uint64(popupWV.id)). | |
| 366 | + | Msg("cef: native popup attach timed out, creating popup browser directly") | |
| 367 | + | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 368 | + | }) | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | if !popupWV.preparePopupShellDirectBrowserCreation() { | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly") | |
| 376 | + | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 377 | + | } | |
| 378 | + | return popupWV, nil | |
| 284 | 379 | } | |
M internal/infrastructure/cef/factory_resize_test.go +70 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 6 | + | "time" | |
| 5 | 7 | ||
| 6 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 9 | "github.com/stretchr/testify/require" | |
| @@ -26,3 +28,71 @@ func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) { | |||
| 26 | 28 | ||
| 27 | 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 | 17 | rendererBridgeActionExplicitTextCopy = "explicit_text_copy" | |
| 18 | 18 | rendererBridgeActionFocusSync = "focus_sync" | |
| 19 | 19 | rendererBridgeActionEditableFocusChanged = "editable_focus_changed" | |
| 20 | + | rendererBridgeActionPopupOpen = "popup_open" | |
| 21 | + | rendererBridgeActionPopupNavigate = "popup_navigate" | |
| 22 | + | rendererBridgeActionPopupClose = "popup_close" | |
| 20 | 23 | rendererBridgeActionReady = "bridge_ready" | |
| 21 | 24 | rendererBridgeExtensionName = "dumber.renderer_bridge" | |
| 22 | 25 | ) | |
| 23 | 26 | ||
| 24 | 27 | 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 | 29 | type rendererBridgeProcessHandler struct { | |
| 222 | 30 | registerOnce sync.Once | |
| 223 | 31 | bridgeHandler purecef.V8Handler | |
| @@ -338,6 +146,23 @@ type rendererBridgeExplicitTextCopyPayload struct { | |||
| 338 | 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 | 166 | func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) { | |
| 342 | 167 | var req rendererBridgeExplicitTextCopyPayload | |
| 343 | 168 | if len(payload) == 0 { | |
| @@ -355,6 +180,38 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge | |||
| 355 | 180 | 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 | 215 | func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) { | |
| 359 | 216 | logger := logging.FromContext(e.currentContext()) | |
| 360 | 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 | 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 | 111 | func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) { | |
| 101 | 112 | require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;") | |
| 102 | 113 | require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {") | |
| @@ -130,6 +141,77 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |||
| 130 | 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 | 215 | func parseCEFPackageFiles(t *testing.T) []*ast.File { | |
| 134 | 216 | t.Helper() | |
| 135 | 217 | entries, err := os.ReadDir(".") | |
M internal/infrastructure/cef/runtime_config.go +2 -1
| @@ -5,11 +5,12 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | domainerrors "github.com/bnema/dumber/internal/domain/errors" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | var ( | |
| 11 | 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 | 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 | 66 | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |
| 67 | 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 | 78 | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | |
| 70 | 79 | // browser/navigation context that issued the request. | |
| 71 | 80 | 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 | 141 | // Route API requests before redirecting conceptual dumb:// URLs to the | |
| 133 | - | // internal HTTPS origin. External pages use dumb://api/... for clipboard and | |
| 134 | - | // focus bridges specifically to bypass page CSP; redirecting those requests | |
| 135 | - | // to https://dumber.invalid makes site connect-src policies block them. | |
| 142 | + | // internal HTTPS origin. External pages use dumb://api/... for clipboard, | |
| 143 | + | // focus, and popup bridges specifically to bypass page CSP; redirecting | |
| 144 | + | // those requests to https://dumber.invalid makes site connect-src policies | |
| 145 | + | // block them. | |
| 136 | 146 | if apiPath, ok := resolveAPIPath(u); ok { | |
| 137 | 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 | 166 | if strings.EqualFold(method, http.MethodOptions) { | |
| 157 | 167 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |
| 158 | 168 | } | |
| 159 | - | ||
| 160 | - | switch { | |
| 161 | - | case path == "/api/message" && strings.EqualFold(method, "POST"): | |
| 162 | - | body := readBodyFromHeader(request) | |
| 163 | - | if body == nil { | |
| 164 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 169 | + | if isAPIGetMethod(method) { | |
| 170 | + | if handler, ok := h.handleAPIGet(path, request); ok { | |
| 171 | + | return handler | |
| 165 | 172 | } | |
| 166 | - | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | |
| 167 | - | if err != nil { | |
| 168 | - | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 173 | + | } | |
| 174 | + | if isAPIPostMethod(method) { | |
| 175 | + | if handler, ok := h.handleAPIPost(browser, path, request); ok { | |
| 176 | + | return handler | |
| 169 | 177 | } | |
| 170 | - | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | |
| 171 | - | ||
| 172 | - | case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")): | |
| 173 | - | return h.handleConfigAPI(h.currentConfigPayload) | |
| 174 | - | ||
| 175 | - | case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")): | |
| 176 | - | return h.handleConfigAPI(h.defaultConfigPayload) | |
| 177 | - | ||
| 178 | - | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 179 | - | return h.handleTranscodeAPI(request) | |
| 178 | + | } | |
| 179 | + | return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 180 | + | } | |
| 180 | 181 | ||
| 181 | - | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 182 | - | return h.handleClipboardSet(request, browser) | |
| 182 | + | func isAPIGetMethod(method string) bool { | |
| 183 | + | return method == "" || strings.EqualFold(method, http.MethodGet) | |
| 184 | + | } | |
| 183 | 185 | ||
| 184 | - | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 185 | - | return h.handleFocusSync(request, browser) | |
| 186 | + | func isAPIPostMethod(method string) bool { | |
| 187 | + | return strings.EqualFold(method, http.MethodPost) | |
| 188 | + | } | |
| 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 | 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 | 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 | 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 | 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 | 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 | 466 | func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool { | |
| 335 | 467 | if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil { | |
| 336 | 468 | return false | |
M internal/infrastructure/cef/scheme_handler_test.go +236 -0
| @@ -70,6 +70,20 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||
| 70 | 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 | 87 | func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |
| 74 | 88 | oldNewResourceHandler := cefNewResourceHandler | |
| 75 | 89 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| @@ -235,6 +249,228 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||
| 235 | 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 | 474 | func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |
| 239 | 475 | oldNewResourceHandler := cefNewResourceHandler | |
| 240 | 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 | 24 | // Compile-time interface checks. | |
| 25 | 25 | var ( | |
| 26 | - | _ port.WebView = (*WebView)(nil) | |
| 27 | - | _ port.NativeWidgetProvider = (*WebView)(nil) | |
| 28 | - | _ port.DevToolsOpener = (*WebView)(nil) | |
| 26 | + | _ port.WebView = (*WebView)(nil) | |
| 27 | + | _ port.NativeWidgetProvider = (*WebView)(nil) | |
| 28 | + | _ port.DevToolsOpener = (*WebView)(nil) | |
| 29 | + | _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 30 | + | _ port.PopupOpenerCapable = (*WebView)(nil) | |
| 31 | + | _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 29 | 32 | ) | |
| 30 | 33 | ||
| 31 | 34 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | |
| @@ -38,6 +41,7 @@ const ( | |||
| 38 | 41 | clipboardSelectionDebounceDelay = 300 * time.Millisecond | |
| 39 | 42 | pendingNavigationRetryDelay = 50 * time.Millisecond | |
| 40 | 43 | pendingNavigationMaxRetries = 80 | |
| 44 | + | nativePopupAttachFallbackDelay = 250 * time.Millisecond | |
| 41 | 45 | ) | |
| 42 | 46 | ||
| 43 | 47 | type stoppableTimer interface { | |
| @@ -94,6 +98,35 @@ type WebView struct { | |||
| 94 | 98 | mu sync.RWMutex | |
| 95 | 99 | 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 | 130 | // State cache (mutex-protected). | |
| 98 | 131 | uri string | |
| 99 | 132 | title string | |
| @@ -126,6 +159,11 @@ type WebView struct { | |||
| 126 | 159 | audioPlaying atomic.Bool | |
| 127 | 160 | 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 | 167 | // Audio output factory and active stream. | |
| 130 | 168 | audioOutputFactory port.AudioOutputFactory | |
| 131 | 169 | audioStreamMu sync.Mutex | |
| @@ -139,9 +177,11 @@ type WebView struct { | |||
| 139 | 177 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 140 | 178 | // deferred until the GL area has a non-zero size. | |
| 141 | 179 | type pendingBrowserCreate struct { | |
| 142 | - | windowInfo *purecef.WindowInfo | |
| 143 | - | client purecef.RawClient | |
| 144 | - | settings *purecef.BrowserSettings | |
| 180 | + | windowInfo *purecef.WindowInfo | |
| 181 | + | client purecef.RawClient | |
| 182 | + | settings *purecef.BrowserSettings | |
| 183 | + | extraInfo purecef.DictionaryValue | |
| 184 | + | postTaskRetries int | |
| 145 | 185 | } | |
| 146 | 186 | ||
| 147 | 187 | type cefTaskFunc func() | |
| @@ -465,6 +505,408 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) { | |||
| 465 | 505 | 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 | 911 | // JavaScript / Appearance | |
| 470 | 912 | // --------------------------------------------------------------------------- | |
| @@ -509,6 +951,19 @@ func (wv *WebView) rotateBridgeNonce() string { | |||
| 509 | 951 | 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 | 967 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | |
| 513 | 968 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | |
| 514 | 969 | script := fmt.Sprintf( | |
| @@ -536,6 +991,23 @@ func (wv *WebView) Destroy() { | |||
| 536 | 991 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 537 | 992 | 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 | 1011 | wv.cancelSelectionDebounce() | |
| 540 | 1012 | if wv.resizeReconciler != nil { | |
| 541 | 1013 | wv.resizeReconciler.stop() | |
| @@ -547,6 +1019,8 @@ func (wv *WebView) Destroy() { | |||
| 547 | 1019 | wv.mu.RUnlock() | |
| 548 | 1020 | if host != nil { | |
| 549 | 1021 | host.CloseBrowser(1) | |
| 1022 | + | } else { | |
| 1023 | + | wv.runCloseCallbacks() | |
| 550 | 1024 | } | |
| 551 | 1025 | if wv.pipeline != nil { | |
| 552 | 1026 | wv.pipeline.destroy() | |
| @@ -593,6 +1067,7 @@ func (wv *WebView) updateURI(uri string) { | |||
| 593 | 1067 | cb.OnURIChanged(uri) | |
| 594 | 1068 | }) | |
| 595 | 1069 | } | |
| 1070 | + | wv.runNavigationCallbacks(uri) | |
| 596 | 1071 | } | |
| 597 | 1072 | ||
| 598 | 1073 | func (wv *WebView) updateTitle(title string) { | |
| @@ -1119,6 +1594,70 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 1119 | 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 | 1661 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |
| 1123 | 1662 | wv.mu.Lock() | |
| 1124 | 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 | 55 | defer wv.mu.RUnlock() | |
| 56 | 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 | 107 | h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true")) | |
| 108 | 108 | case rendererBridgeActionFocusSync: | |
| 109 | 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 | 131 | case rendererBridgeActionReady: | |
| 111 | 132 | log.Debug(). | |
| 112 | 133 | Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)). | |
| @@ -580,6 +601,20 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 580 | 601 | if frame == nil || !frame.IsMain() { | |
| 581 | 602 | 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 | 618 | if h.wv != nil && h.wv.ctx != nil { | |
| 584 | 619 | logging.FromContext(h.wv.ctx).Debug(). | |
| 585 | 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 | 688 | // OnBeforePopup intercepts popup requests (target="_blank", window.open). | |
| 654 | - | // CEF OSR cannot create popup windows, so we fire the OnCreate callback | |
| 655 | - | // to let the coordinator open the link in a new stacked pane. | |
| 689 | + | // When the coordinator returns a related CEF popup shell we hand that shell's | |
| 690 | + | // client back to CEF and allow native popup creation so opener semantics are | |
| 691 | + | // preserved. Otherwise we keep blocking and let the coordinator's fallback | |
| 692 | + | // pane handle the navigation. | |
| 656 | 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 | 695 | _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | |
| 659 | - | _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, _ *purecef.BrowserSettings, | |
| 660 | - | _ *purecef.DictionaryValue, _ *bool, | |
| 696 | + | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | |
| 697 | + | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | |
| 661 | 698 | ) bool { | |
| 662 | 699 | if targetURL == "" { | |
| 663 | 700 | return true | |
| @@ -666,23 +703,47 @@ func (h *handlerSet) OnBeforePopup( | |||
| 666 | 703 | h.wv.mu.RLock() | |
| 667 | 704 | cb := h.wv.callbacks | |
| 668 | 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 { | |
| 671 | - | req := port.PopupRequest{ | |
| 672 | - | TargetURI: targetURL, | |
| 673 | - | FrameName: targetFrameName, | |
| 674 | - | IsUserGesture: userGesture != 0, | |
| 675 | - | ParentViewID: h.wv.id, | |
| 676 | - | } | |
| 677 | - | h.wv.runOnGTK(func() { | |
| 678 | - | cb.OnCreate(req) | |
| 710 | + | requestNoJavaScriptAccess := false | |
| 711 | + | if noJavaScriptAccess != nil { | |
| 712 | + | requestNoJavaScriptAccess = *noJavaScriptAccess | |
| 713 | + | } | |
| 714 | + | var popup port.WebView | |
| 715 | + | h.wv.runOnGTKSync(func() { | |
| 716 | + | popup = cb.OnCreate(port.PopupRequest{ | |
| 717 | + | TargetURI: targetURL, | |
| 718 | + | FrameName: targetFrameName, | |
| 719 | + | IsUserGesture: userGesture != 0, | |
| 720 | + | NoJavaScriptAccess: requestNoJavaScriptAccess, | |
| 721 | + | ParentViewID: h.wv.id, | |
| 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 pane | |
| 734 | + | cefPopup.discardNativePopupCandidate() | |
| 735 | + | logging.FromContext(h.currentContext()).Debug(). | |
| 736 | + | Int32("popup_id", popupID). | |
| 737 | + | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 738 | + | Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation") | |
| 739 | + | return true | |
| 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 | 748 | func (h *handlerSet) OnBeforeDevToolsPopup( | |
| 688 | 749 | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, | |
| @@ -698,45 +759,88 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 698 | 759 | return | |
| 699 | 760 | } | |
| 700 | 761 | browserID := browser.GetIdentifier() | |
| 701 | - | log.Debug(). | |
| 702 | - | Int32("browser_id", browserID). | |
| 703 | - | Msg("cef: OnAfterCreated") | |
| 704 | - | if h.wv.engine != nil { | |
| 705 | - | h.wv.engine.recordBrowserAfterCreated(browser) | |
| 706 | - | h.wv.engine.registerWebView(h.wv) | |
| 707 | - | } | |
| 762 | + | log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated") | |
| 763 | + | ||
| 708 | 764 | host := browser.GetHost() | |
| 709 | 765 | if host == nil { | |
| 710 | 766 | log.Warn().Msg("cef: OnAfterCreated returned nil host") | |
| 711 | 767 | return | |
| 712 | 768 | } | |
| 713 | 769 | ||
| 714 | - | shouldSyncFocus := false | |
| 715 | - | hasPendingNavigation := false | |
| 770 | + | state := h.attachAfterCreatedBrowser(browser, host, browserID) | |
| 771 | + | if state.closeDuplicate { | |
| 772 | + | log.Warn(). | |
| 773 | + | Int32("browser_id", browserID). | |
| 774 | + | Int32("existing_browser_id", state.duplicateBrowserID). | |
| 775 | + | Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate") | |
| 776 | + | host.CloseBrowser(1) | |
| 777 | + | return | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | h.finishAfterCreated(browser, host, state) | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | func (h *handlerSet) attachAfterCreatedBrowser( | |
| 784 | + | browser purecef.Browser, | |
| 785 | + | host purecef.BrowserHost, | |
| 786 | + | browserID int32, | |
| 787 | + | ) afterCreatedState { | |
| 788 | + | state := afterCreatedState{} | |
| 716 | 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 | 801 | h.wv.browser = browser | |
| 718 | 802 | h.wv.host = host | |
| 803 | + | h.wv.pendingCreate = nil | |
| 719 | 804 | h.wv.input.setHost(host) | |
| 720 | 805 | if h.wv.findCtrl != nil { | |
| 721 | 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 | 814 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 725 | 815 | // painting/caret updates until explicitly told the browser is shown. | |
| 726 | 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 focus | |
| 729 | - | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | |
| 730 | - | if h.wv.input != nil { | |
| 731 | - | shouldSyncFocus = h.wv.input.hasGTKFocus() | |
| 732 | - | if !shouldSyncFocus && h.wv.input.glArea != nil { | |
| 733 | - | shouldSyncFocus = h.wv.input.glArea.HasFocus() | |
| 734 | - | } | |
| 822 | + | func (h *handlerSet) afterCreatedShouldSyncFocus() bool { | |
| 823 | + | if h.wv.input == nil { | |
| 824 | + | return false | |
| 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() | |
| 739 | - | if shouldSyncFocus { | |
| 832 | + | func (h *handlerSet) finishAfterCreated( | |
| 833 | + | browser purecef.Browser, | |
| 834 | + | host purecef.BrowserHost, | |
| 835 | + | state afterCreatedState, | |
| 836 | + | ) { | |
| 837 | + | if h.wv.engine != nil { | |
| 838 | + | h.wv.engine.recordBrowserAfterCreated(browser) | |
| 839 | + | h.wv.engine.registerWebView(h.wv) | |
| 840 | + | h.wv.engine.bindBrowserWebView(browser, h.wv) | |
| 841 | + | } | |
| 842 | + | h.wv.stopNativePopupFallbackTimer() | |
| 843 | + | if state.shouldSyncFocus { | |
| 740 | 844 | syncWindowlessBrowserFocus(host) | |
| 741 | 845 | } else { | |
| 742 | 846 | // Request an initial OSR frame even before the first real navigation | |
| @@ -744,11 +848,23 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 744 | 848 | // startup path relied on and prevents the render pipeline from staying idle. | |
| 745 | 849 | host.Invalidate(purecef.PaintElementTypePetView) | |
| 746 | 850 | } | |
| 747 | - | if hasPendingNavigation { | |
| 851 | + | if state.hasPendingNavigation { | |
| 748 | 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 | 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 | 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 | 875 | // OnBeforeClose fires the OnClose callback. | |
| 760 | - | func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |
| 876 | + | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |
| 761 | 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 | 884 | h.wv.mu.Lock() | |
| 765 | 885 | h.wv.browser = nil | |
| @@ -780,6 +900,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 780 | 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 | 101 | 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 | 257 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | |
| 105 | 258 | wv := &WebView{ctx: context.Background()} | |
| 106 | 259 | var gotEvents []port.LoadEvent | |
| @@ -282,6 +435,15 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 282 | 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 | 447 | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | |
| 286 | 448 | scheduler := &controlledSelectionScheduler{} | |
| 287 | 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 | 30 | var _ port.WebView = (*WebView)(nil) | |
| 31 | 31 | var _ port.DevToolsOpener = (*WebView)(nil) | |
| 32 | 32 | var _ port.Printer = (*WebView)(nil) | |
| 33 | + | var _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 33 | 34 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 34 | 35 | ||
| 35 | 36 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| @@ -1584,15 +1585,19 @@ func (wv *WebView) Show() { | |||
| 1584 | 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 | 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 | 1594 | func (wv *WebView) SetOnReadyToShow(fn func()) { | |
| 1590 | 1595 | wv.OnReadyToShow = fn | |
| 1591 | 1596 | } | |
| 1592 | 1597 | ||
| 1593 | 1598 | // SetOnClose composes fn with any existing OnClose handler so multiple callers | |
| 1594 | 1599 | // can each register a close callback without overwriting one another. | |
| 1595 | - | // It implements port.PopupCapable. | |
| 1600 | + | // It implements port.PopupLifecycleCapable. | |
| 1596 | 1601 | func (wv *WebView) SetOnClose(fn func()) { | |
| 1597 | 1602 | existing := wv.OnClose | |
| 1598 | 1603 | if existing == nil { | |
M internal/ui/coordinator/content/coordinator.go +75 -32
| @@ -3,7 +3,6 @@ package content | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -29,8 +28,9 @@ type Coordinator struct { | |||
| 29 | 28 | settingsApplier port.SettingsApplier // optional: nil if engine doesn't support | |
| 30 | 29 | filterApplier port.FilterApplier // optional: nil if engine doesn't support | |
| 31 | 30 | ||
| 32 | - | webViews map[entity.PaneID]port.WebView | |
| 33 | - | webViewsMu sync.RWMutex | |
| 31 | + | webViews map[entity.PaneID]port.WebView | |
| 32 | + | webViewPaneIDs map[port.WebViewID]entity.PaneID | |
| 33 | + | webViewsMu sync.RWMutex | |
| 34 | 34 | ||
| 35 | 35 | activePaneOverride entity.PaneID | |
| 36 | 36 | activePaneOverrideMu sync.RWMutex | |
| @@ -81,23 +81,10 @@ type Coordinator struct { | |||
| 81 | 81 | // Gesture action handler for mouse button navigation | |
| 82 | 82 | gestureActionHandler input.ActionHandler | |
| 83 | 83 | ||
| 84 | - | // Popup handling | |
| 85 | - | factory port.WebViewFactory | |
| 86 | - | popupConfig *entity.PopupBehaviorConfig | |
| 87 | - | pendingPopups map[port.WebViewID]*PendingPopup | |
| 88 | - | namedPopups map[namedPopupKey]*namedPopupState | |
| 89 | - | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 90 | - | popupRefresh map[entity.PaneID]*time.Timer | |
| 91 | - | popupMu sync.RWMutex | |
| 92 | - | ||
| 93 | - | // Callback to insert popup into workspace (avoids circular dependency) | |
| 94 | - | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | |
| 95 | - | ||
| 96 | - | // Callback to close a pane when popup closes | |
| 97 | - | onClosePane func(ctx context.Context, paneID entity.PaneID) error | |
| 98 | - | ||
| 99 | - | // ID generator for popup panes | |
| 100 | - | generateID func() string | |
| 84 | + | // Popup handling stays in a dedicated UI-layer manager so popup-specific | |
| 85 | + | // pane state does not bloat the main coordinator. | |
| 86 | + | popups *popupManager | |
| 87 | + | popupsOnce sync.Once | |
| 101 | 88 | ||
| 102 | 89 | // Idle inhibitor for fullscreen video playback | |
| 103 | 90 | idleInhibitor port.IdleInhibitor | |
| @@ -122,14 +109,6 @@ type pendingThemeUpdate struct { | |||
| 122 | 109 | 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 | 112 | // PermissionActivityState represents the visible state for media permission activity. | |
| 134 | 113 | type PermissionActivityState string | |
| 135 | 114 | ||
| @@ -162,17 +141,28 @@ func NewCoordinator( | |||
| 162 | 141 | zoomUC: zoomUC, | |
| 163 | 142 | permissionUC: permissionUC, | |
| 164 | 143 | webViews: make(map[entity.PaneID]port.WebView), | |
| 144 | + | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | |
| 165 | 145 | paneTitles: make(map[entity.PaneID]string), | |
| 166 | 146 | navOrigins: make(map[entity.PaneID]string), | |
| 167 | 147 | pendingReveal: make(map[entity.PaneID]bool), | |
| 168 | 148 | pendingScriptRefresh: make(map[entity.PaneID]bool), | |
| 169 | 149 | pendingThemePanes: make(map[entity.PaneID]bool), | |
| 170 | 150 | getActiveWS: getActiveWS, | |
| 171 | - | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 172 | - | namedPopups: make(map[namedPopupKey]*namedPopupState), | |
| 173 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 174 | - | popupRefresh: make(map[entity.PaneID]*time.Timer), | |
| 151 | + | popups: newPopupManager(), | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (c *Coordinator) ensurePopupManager() *popupManager { | |
| 156 | + | if c == nil { | |
| 157 | + | return nil | |
| 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 | 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 | 299 | func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { | |
| 310 | 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 | 307 | c.webViews[paneID] = wv | |
| 308 | + | if wv != nil && c.webViewPaneIDs != nil { | |
| 309 | + | c.webViewPaneIDs[wv.ID()] = paneID | |
| 310 | + | } | |
| 312 | 311 | c.webViewsMu.Unlock() | |
| 313 | 312 | } | |
| 314 | 313 | ||
| @@ -317,5 +316,49 @@ func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView { | |||
| 317 | 316 | defer c.webViewsMu.Unlock() | |
| 318 | 317 | wv := c.webViews[paneID] | |
| 319 | 318 | delete(c.webViews, paneID) | |
| 319 | + | if wv != nil && c.webViewPaneIDs != nil { | |
| 320 | + | delete(c.webViewPaneIDs, wv.ID()) | |
| 321 | + | } | |
| 320 | 322 | 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( | |
| 210 | - | existing func(port.LoadEvent), | |
| 211 | - | next func(port.LoadEvent), | |
| 212 | - | ) func(port.LoadEvent) { | |
| 209 | + | func composeOnLoadChanged(existing, next func(port.LoadEvent)) func(port.LoadEvent) { | |
| 213 | 210 | if existing == nil { | |
| 214 | 211 | return next | |
| 215 | 212 | } | |
| @@ -227,6 +224,14 @@ func composeOnLoadChanged( | |||
| 227 | 224 | // For providers using postMessage (like Google Sign-In), we rely on the provider calling | |
| 228 | 225 | // window.close() which triggers WebKit's close signal. | |
| 229 | 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 | 235 | func (c *Coordinator) setupOAuthAutoClose( | |
| 231 | 236 | ctx context.Context, | |
| 232 | 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) { | |
| 291 | - | c.capturePopupOAuthState(popupID, uri) | |
| 295 | + | requestOAuthClose := func(reason string) { | |
| 292 | 296 | cancelSafetyTimer() | |
| 293 | 297 | log.Info(). | |
| 294 | 298 | Str("pane", string(paneID)). | |
| @@ -309,13 +313,38 @@ func (c *Coordinator) setupOAuthAutoClose( | |||
| 309 | 313 | ||
| 310 | 314 | // Start safety timer immediately. | |
| 311 | 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 | 323 | // Register navigation callback to check for OAuth callbacks on URI changes and committed loads. | |
| 314 | 324 | oauthWV.AddNavigationCallback(func(uri string) { | |
| 315 | - | if ShouldAutoClose(uri) { | |
| 316 | - | requestOAuthClose(uri, "navigation") | |
| 325 | + | if !ShouldAutoClose(uri) { | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | c.capturePopupOAuthState(popupID, uri) | |
| 329 | + | if deferCloseOnNavigation { | |
| 330 | + | cancelSafetyTimer() | |
| 331 | + | return | |
| 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 | 349 | // Cancel safety timer on any close path. | |
| 321 | 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) { | |
| 327 | - | c.popupMu.Lock() | |
| 328 | - | defer c.popupMu.Unlock() | |
| 329 | - | if c.popupOAuth == nil { | |
| 330 | - | c.popupOAuth = make(map[port.WebViewID]*popupOAuthState) | |
| 331 | - | } | |
| 332 | - | c.popupOAuth[popupID] = &popupOAuthState{ | |
| 333 | - | ParentPaneID: parentPaneID, | |
| 334 | - | } | |
| 355 | + | func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | |
| 356 | + | c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen) | |
| 335 | 357 | } | |
| 336 | 358 | ||
| 337 | 359 | func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) { | |
| 338 | - | c.popupMu.Lock() | |
| 339 | - | defer c.popupMu.Unlock() | |
| 340 | - | ||
| 341 | - | state, ok := c.popupOAuth[popupID] | |
| 342 | - | if !ok { | |
| 343 | - | return | |
| 344 | - | } | |
| 360 | + | c.ensurePopupManager().capturePopupOAuthState(popupID, uri) | |
| 361 | + | } | |
| 345 | 362 | ||
| 346 | - | state.Seen = true | |
| 347 | - | state.CallbackURI = uri | |
| 348 | - | state.Success = IsOAuthSuccess(uri) | |
| 349 | - | state.Error = IsOAuthError(uri) | |
| 363 | + | func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) { | |
| 364 | + | c.ensurePopupManager().capturePopupOAuthMessage(popupID) | |
| 350 | 365 | } | |
| 351 | 366 | ||
| 352 | 367 | func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) { | |
| 353 | 368 | log := logging.FromContext(ctx) | |
| 354 | 369 | ||
| 355 | - | c.popupMu.Lock() | |
| 356 | - | state, ok := c.popupOAuth[popupID] | |
| 357 | - | if ok { | |
| 358 | - | delete(c.popupOAuth, popupID) | |
| 359 | - | } | |
| 360 | - | c.popupMu.Unlock() | |
| 370 | + | state, ok := c.ensurePopupManager().takePopupOAuthState(popupID) | |
| 361 | 371 | ||
| 362 | 372 | if !ok || state == nil || !state.Seen { | |
| 363 | 373 | return | |
| @@ -370,42 +380,78 @@ func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.We | |||
| 370 | 380 | Bool("oauth_error", state.Error). | |
| 371 | 381 | Msg("popup oauth result captured on close") | |
| 372 | 382 | ||
| 373 | - | if !state.Success || state.ParentPaneID == "" { | |
| 383 | + | if state.ParentPaneID == "" { | |
| 374 | 384 | 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 | 418 | ctx context.Context, | |
| 382 | 419 | parentPaneID entity.PaneID, | |
| 383 | 420 | popupID port.WebViewID, | |
| 421 | + | parentURIAtOpen, callbackURI string, | |
| 422 | + | remainingGrace int, | |
| 384 | 423 | ) { | |
| 385 | - | c.popupMu.Lock() | |
| 386 | - | if c.popupRefresh == nil { | |
| 387 | - | c.popupRefresh = make(map[entity.PaneID]*time.Timer) | |
| 388 | - | } | |
| 389 | - | if existing := c.popupRefresh[parentPaneID]; existing != nil { | |
| 390 | - | existing.Stop() | |
| 391 | - | } | |
| 392 | - | c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() { | |
| 393 | - | c.popupMu.Lock() | |
| 394 | - | delete(c.popupRefresh, parentPaneID) | |
| 395 | - | c.popupMu.Unlock() | |
| 424 | + | c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() { | |
| 396 | 425 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 397 | - | c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID) | |
| 426 | + | c.resumeParentPaneAfterOAuthAttempt(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI, remainingGrace) | |
| 398 | 427 | return false | |
| 399 | 428 | }) | |
| 400 | 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 | 434 | ctx context.Context, | |
| 407 | 435 | parentPaneID entity.PaneID, | |
| 408 | 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 | 456 | log := logging.FromContext(ctx) | |
| 411 | 457 | wv := c.getWebViewLocked(parentPaneID) | |
| @@ -413,7 +459,38 @@ func (c *Coordinator) refreshPaneAfterOAuth( | |||
| 413 | 459 | log.Debug(). | |
| 414 | 460 | Str("parent_pane_id", string(parentPaneID)). | |
| 415 | 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 | 494 | return | |
| 418 | 495 | } | |
| 419 | 496 | ||
| @@ -429,5 +506,38 @@ func (c *Coordinator) refreshPaneAfterOAuth( | |||
| 429 | 506 | log.Info(). | |
| 430 | 507 | Str("parent_pane_id", string(parentPaneID)). | |
| 431 | 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 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | 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 | 69 | func TestComposeOnClose_Order(t *testing.T) { | |
| 15 | 70 | var calls []string | |
| 16 | 71 | ||
| @@ -47,52 +102,154 @@ func TestComposeOnLoadChanged_Order(t *testing.T) { | |||
| 47 | 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 | 147 | parentPaneID := entity.PaneID("parent-pane") | |
| 52 | 148 | popupID := port.WebViewID(101) | |
| 53 | 149 | ||
| 54 | 150 | c := &Coordinator{ | |
| 55 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 56 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 57 | - | popupRefresh: make(map[entity.PaneID]*time.Timer), | |
| 151 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 152 | + | popups: newPopupManager(), | |
| 58 | 153 | } | |
| 59 | 154 | ||
| 60 | - | c.trackOAuthPopup(popupID, parentPaneID) | |
| 61 | - | c.capturePopupOAuthState(popupID, "https://app/callback?code=123") | |
| 155 | + | c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login") | |
| 156 | + | c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123") | |
| 62 | 157 | c.handlePopupOAuthClose(context.Background(), popupID) | |
| 63 | 158 | ||
| 64 | - | c.popupMu.RLock() | |
| 65 | - | _, exists := c.popupOAuth[popupID] | |
| 66 | - | refreshTimer := c.popupRefresh[parentPaneID] | |
| 67 | - | c.popupMu.RUnlock() | |
| 159 | + | c.popups.mu.RLock() | |
| 160 | + | _, exists := c.popups.popupOAuth[popupID] | |
| 161 | + | refreshTimer := c.popups.popupRefresh[parentPaneID] | |
| 162 | + | c.popups.mu.RUnlock() | |
| 68 | 163 | ||
| 69 | 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 | 167 | waitFor(t, time.Second, func() bool { | |
| 73 | - | c.popupMu.RLock() | |
| 74 | - | defer c.popupMu.RUnlock() | |
| 75 | - | return c.popupRefresh[parentPaneID] == nil | |
| 168 | + | c.popups.mu.RLock() | |
| 169 | + | defer c.popups.mu.RUnlock() | |
| 170 | + | return c.popups.popupRefresh[parentPaneID] == nil | |
| 76 | 171 | }) | |
| 77 | 172 | } | |
| 78 | 173 | ||
| 79 | - | func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) { | |
| 174 | + | func TestResumeParentPaneAfterOAuth_UnchangedParentDefersSameSiteFallbackReload(t *testing.T) { | |
| 80 | 175 | parentPaneID := entity.PaneID("parent-pane") | |
| 81 | 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 | 181 | c := &Coordinator{ | |
| 84 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 85 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 86 | - | popupRefresh: make(map[entity.PaneID]*time.Timer), | |
| 182 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 183 | + | parentPaneID: parentWV, | |
| 184 | + | }, | |
| 185 | + | popups: newPopupManager(), | |
| 87 | 186 | } | |
| 88 | 187 | ||
| 89 | - | c.trackOAuthPopup(popupID, parentPaneID) | |
| 90 | - | c.capturePopupOAuthState(popupID, "https://app/callback?error=access_denied") | |
| 91 | - | c.handlePopupOAuthClose(context.Background(), popupID) | |
| 188 | + | c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123") | |
| 189 | + | ||
| 190 | + | c.popups.mu.Lock() | |
| 191 | + | refreshTimer := c.popups.popupRefresh[parentPaneID] | |
| 192 | + | if refreshTimer != nil { | |
| 193 | + | refreshTimer.Stop() | |
| 194 | + | delete(c.popups.popupRefresh, parentPaneID) | |
| 195 | + | } | |
| 196 | + | c.popups.mu.Unlock() | |
| 197 | + | ||
| 198 | + | assert.NotNil(t, refreshTimer, "same-site popup callback should get a grace retry before forcing reload") | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestResumeParentPaneAfterOAuth_ChangedParentSkipsIntervention(t *testing.T) { | |
| 202 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 203 | + | popupID := port.WebViewID(103) | |
| 204 | + | parentWV := portmocks.NewMockWebView(t) | |
| 205 | + | parentWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 206 | + | parentWV.EXPECT().URI().Return("https://www.notion.so/oauth2callback?state=abc").Once() | |
| 207 | + | ||
| 208 | + | c := &Coordinator{ | |
| 209 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 210 | + | parentPaneID: parentWV, | |
| 211 | + | }, | |
| 212 | + | popups: newPopupManager(), | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123") | |
| 216 | + | parentWV.AssertNotCalled(t, "Reload", mock.Anything) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestResumeParentPaneAfterOAuthAttempt_GraceExhaustedFallsBackToReload(t *testing.T) { | |
| 220 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 221 | + | popupID := port.WebViewID(104) | |
| 222 | + | parentWV := portmocks.NewMockWebView(t) | |
| 223 | + | parentWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 224 | + | parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once() | |
| 225 | + | parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once() | |
| 226 | + | ||
| 227 | + | c := &Coordinator{ | |
| 228 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 229 | + | parentPaneID: parentWV, | |
| 230 | + | }, | |
| 231 | + | popups: newPopupManager(), | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | c.resumeParentPaneAfterOAuthAttempt(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123", 0) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestResumeParentPaneAfterOAuth_DifferentDomainFallsBackToReload(t *testing.T) { | |
| 238 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 239 | + | popupID := port.WebViewID(105) | |
| 240 | + | parentWV := portmocks.NewMockWebView(t) | |
| 241 | + | parentWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 242 | + | parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once() | |
| 243 | + | parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once() | |
| 244 | + | ||
| 245 | + | c := &Coordinator{ | |
| 246 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 247 | + | parentPaneID: parentWV, | |
| 248 | + | }, | |
| 249 | + | popups: newPopupManager(), | |
| 250 | + | } | |
| 92 | 251 | ||
| 93 | - | c.popupMu.RLock() | |
| 94 | - | defer c.popupMu.RUnlock() | |
| 95 | - | assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane") | |
| 252 | + | c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://accounts.example.com/callback?code=123") | |
| 96 | 253 | } | |
| 97 | 254 | ||
| 98 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "fmt" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -92,69 +91,6 @@ func isReusableNamedPopupFrame(frameName string) bool { | |||
| 92 | 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 | 94 | // InsertPopupInput contains the data needed to insert a popup into the workspace. | |
| 159 | 95 | type InsertPopupInput struct { | |
| 160 | 96 | // ParentPaneID is the pane that spawned this popup. | |
| @@ -213,19 +149,17 @@ func (c *Coordinator) SetPopupConfig( | |||
| 213 | 149 | popupConfig *entity.PopupBehaviorConfig, | |
| 214 | 150 | generateID func() string, | |
| 215 | 151 | ) { | |
| 216 | - | c.factory = factory | |
| 217 | - | c.popupConfig = popupConfig | |
| 218 | - | c.generateID = generateID | |
| 152 | + | c.ensurePopupManager().setConfig(factory, popupConfig, generateID) | |
| 219 | 153 | } | |
| 220 | 154 | ||
| 221 | 155 | // SetOnInsertPopup sets the callback to insert popups into the workspace. | |
| 222 | 156 | func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) { | |
| 223 | - | c.onInsertPopup = fn | |
| 157 | + | c.ensurePopupManager().setOnInsertPopup(fn) | |
| 224 | 158 | } | |
| 225 | 159 | ||
| 226 | 160 | // SetOnClosePane sets the callback to close a pane when its popup closes. | |
| 227 | 161 | func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) { | |
| 228 | - | c.onClosePane = fn | |
| 162 | + | c.ensurePopupManager().setOnClosePane(fn) | |
| 229 | 163 | } | |
| 230 | 164 | ||
| 231 | 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. | |
| 249 | - | func (c *Coordinator) createPopupPane( | |
| 250 | - | popupID port.WebViewID, | |
| 251 | - | parentPaneID entity.PaneID, | |
| 252 | - | targetURI string, | |
| 253 | - | ) (entity.PaneID, *entity.Pane) { | |
| 254 | - | var paneID entity.PaneID | |
| 255 | - | if c.generateID != nil { | |
| 256 | - | paneID = entity.PaneID(c.generateID()) | |
| 257 | - | } else { | |
| 258 | - | paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID)) | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | popupPane := entity.NewPane(paneID) | |
| 262 | - | popupPane.WindowType = entity.WindowPopup | |
| 263 | - | popupPane.IsRelated = true | |
| 264 | - | popupPane.ParentPaneID = &parentPaneID | |
| 265 | - | popupPane.URI = targetURI | |
| 266 | - | ||
| 267 | - | return paneID, popupPane | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // handlePopupCreate handles the WebKit "create" signal for popup windows. | |
| 271 | - | // Returns a new related WebView if popup is allowed, nil to block. | |
| 182 | + | // handlePopupCreate handles a popup request from the current WebView. | |
| 183 | + | // Returns a WebView if popup handling is allowed, nil to block. | |
| 272 | 184 | // | |
| 273 | 185 | // 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 | 187 | // The WebView stays hidden until ready-to-show signal is received. | |
| 276 | 188 | func (c *Coordinator) handlePopupCreate( | |
| 277 | 189 | ctx context.Context, | |
| @@ -279,384 +191,11 @@ func (c *Coordinator) handlePopupCreate( | |||
| 279 | 191 | parentWV port.WebView, | |
| 280 | 192 | req port.PopupRequest, | |
| 281 | 193 | ) port.WebView { | |
| 282 | - | log := logging.FromContext(ctx) | |
| 283 | - | ||
| 284 | - | log.Debug(). | |
| 285 | - | Str("parent_pane", string(parentPaneID)). | |
| 286 | - | Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)). | |
| 287 | - | Str("frame_name", req.FrameName). | |
| 288 | - | Bool("user_gesture", req.IsUserGesture). | |
| 289 | - | Msg("popup create request") | |
| 290 | - | ||
| 291 | - | // Check if popups are enabled | |
| 292 | - | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |
| 293 | - | log.Debug().Msg("popups disabled by config, blocking") | |
| 294 | - | return nil | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Check if factory is available | |
| 298 | - | if c.factory == nil { | |
| 299 | - | log.Warn().Msg("no webview factory, cannot create popup") | |
| 300 | - | return nil | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | parentID := parentWV.ID() | |
| 304 | - | if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok { | |
| 305 | - | return reused | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | // Create related WebView for session sharing (created hidden) | |
| 309 | - | popupWV, err := c.factory.CreateRelated(ctx, parentID) | |
| 310 | - | if err != nil { | |
| 311 | - | log.Error().Err(err).Msg("failed to create related webview for popup") | |
| 312 | - | return nil | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | // Detect popup type | |
| 316 | - | popupType := DetectPopupType(req.FrameName) | |
| 317 | - | popupID := popupWV.ID() | |
| 318 | - | ||
| 319 | - | // Determine behavior from config | |
| 320 | - | behavior := GetBehavior(popupType, c.popupConfig) | |
| 321 | - | placement := "right" | |
| 322 | - | if c.popupConfig != nil { | |
| 323 | - | placement = c.popupConfig.Placement | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | // Create popup pane entity | |
| 327 | - | paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI) | |
| 328 | - | ||
| 329 | - | return c.finishPopupCreate(ctx, parentPaneID, parentID, popupID, popupWV, popupPane, paneID, popupType, behavior, placement, req) | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | func (c *Coordinator) reuseNamedPopup( | |
| 333 | - | ctx context.Context, | |
| 334 | - | parentPaneID entity.PaneID, | |
| 335 | - | frameName string, | |
| 336 | - | targetURI string, | |
| 337 | - | ) (port.WebView, bool) { | |
| 338 | - | log := logging.FromContext(ctx) | |
| 339 | - | ||
| 340 | - | if existing, ok := c.lookupReusableNamedPopup(parentPaneID, frameName); ok { | |
| 341 | - | c.updatePendingPopupTarget(existing.WebView.ID(), targetURI) | |
| 342 | - | if err := existing.WebView.LoadURI(ctx, targetURI); err != nil { | |
| 343 | - | log.Warn().Err(err). | |
| 344 | - | Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)). | |
| 345 | - | Msg("failed to load target URI in reused popup") | |
| 346 | - | } | |
| 347 | - | log.Info(). | |
| 348 | - | Str("parent_pane", string(parentPaneID)). | |
| 349 | - | Str("frame_name", frameName). | |
| 350 | - | Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)). | |
| 351 | - | Msg("reused named popup") | |
| 352 | - | return existing.WebView, true | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | return nil, false | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | func (c *Coordinator) finishPopupCreate( | |
| 359 | - | ctx context.Context, | |
| 360 | - | parentPaneID entity.PaneID, | |
| 361 | - | parentID port.WebViewID, | |
| 362 | - | popupID port.WebViewID, | |
| 363 | - | popupWV port.WebView, | |
| 364 | - | popupPane *entity.Pane, | |
| 365 | - | paneID entity.PaneID, | |
| 366 | - | popupType PopupType, | |
| 367 | - | behavior entity.PopupBehavior, | |
| 368 | - | placement string, | |
| 369 | - | req port.PopupRequest, | |
| 370 | - | ) port.WebView { | |
| 371 | - | log := logging.FromContext(ctx) | |
| 372 | - | hasConfig := c.popupConfig != nil | |
| 373 | - | oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose | |
| 374 | - | isOAuth := IsOAuthURL(req.TargetURI) | |
| 375 | - | ||
| 376 | - | log.Debug(). | |
| 377 | - | Bool("has_config", hasConfig). | |
| 378 | - | Bool("oauth_enabled", oauthEnabled). | |
| 379 | - | Bool("is_oauth", isOAuth). | |
| 380 | - | Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)). | |
| 381 | - | Msg("popup OAuth check") | |
| 382 | - | ||
| 383 | - | // Insert into workspace IMMEDIATELY (WebView stays hidden) | |
| 384 | - | // This is required for WebKit to establish window.opener relationship | |
| 385 | - | if c.onInsertPopup != nil { | |
| 386 | - | popupInput := InsertPopupInput{ | |
| 387 | - | ParentPaneID: parentPaneID, | |
| 388 | - | PopupPane: popupPane, | |
| 389 | - | WebView: popupWV, | |
| 390 | - | Behavior: behavior, | |
| 391 | - | Placement: placement, | |
| 392 | - | PopupType: popupType, | |
| 393 | - | TargetURI: req.TargetURI, | |
| 394 | - | } | |
| 395 | - | ||
| 396 | - | if err := c.onInsertPopup(ctx, popupInput); err != nil { | |
| 397 | - | log.Error().Err(err).Msg("failed to insert popup into workspace") | |
| 398 | - | popupWV.Destroy() | |
| 399 | - | return nil | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | // Register WebView in our map (after successful insertion) | |
| 404 | - | c.setWebViewLocked(paneID, popupWV) | |
| 405 | - | c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV) | |
| 406 | - | ||
| 407 | - | // Setup standard callbacks (after successful insertion to avoid leak) | |
| 408 | - | c.setupWebViewCallbacks(ctx, paneID, popupWV) | |
| 409 | - | ||
| 410 | - | // Setup OAuth auto-close if configured | |
| 411 | - | if hasConfig && oauthEnabled && isOAuth { | |
| 412 | - | popupPane.AutoClose = true | |
| 413 | - | c.trackOAuthPopup(popupID, parentPaneID) | |
| 414 | - | c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV) | |
| 415 | - | log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup") | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | // Store pending popup for ready-to-show handling (just visibility now) | |
| 419 | - | pending := &PendingPopup{ | |
| 420 | - | PaneID: paneID, | |
| 421 | - | WebView: popupWV, | |
| 422 | - | ParentPaneID: parentPaneID, | |
| 423 | - | ParentWebViewID: parentID, | |
| 424 | - | TargetURI: req.TargetURI, | |
| 425 | - | FrameName: req.FrameName, | |
| 426 | - | IsUserGesture: req.IsUserGesture, | |
| 427 | - | PopupType: popupType, | |
| 428 | - | CreatedAt: time.Now(), | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | c.popupMu.Lock() | |
| 432 | - | c.pendingPopups[popupID] = pending | |
| 433 | - | c.popupMu.Unlock() | |
| 434 | - | ||
| 435 | - | // Wire ready-to-show and close signals via the PopupCapable interface. | |
| 436 | - | if pc, ok := popupWV.(port.PopupCapable); ok { | |
| 437 | - | pc.SetOnReadyToShow(func() { | |
| 438 | - | c.handlePopupReadyToShow(ctx, popupID) | |
| 439 | - | }) | |
| 440 | - | pc.SetOnClose(func() { | |
| 441 | - | c.handlePopupClose(ctx, popupID) | |
| 442 | - | }) | |
| 443 | - | log.Info(). | |
| 444 | - | Uint64("popup_id", uint64(popupID)). | |
| 445 | - | Str("pane_id", string(paneID)). | |
| 446 | - | Str("popup_type", popupType.String()). | |
| 447 | - | Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)). | |
| 448 | - | Msg("popup inserted (hidden), awaiting ready-to-show for visibility") | |
| 449 | - | } else { | |
| 450 | - | // Engine does not support PopupCapable (e.g. CEF OSR where we create | |
| 451 | - | // an independent browser, not a real CEF popup). The WebView is ready | |
| 452 | - | // immediately — fire ready-to-show inline. | |
| 453 | - | log.Info(). | |
| 454 | - | Uint64("popup_id", uint64(popupID)). | |
| 455 | - | Str("pane_id", string(paneID)). | |
| 456 | - | Str("popup_type", popupType.String()). | |
| 457 | - | Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)). | |
| 458 | - | Msg("popup inserted, immediately ready (no PopupCapable)") | |
| 459 | - | c.handlePopupReadyToShow(ctx, popupID) | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | return popupWV | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | // handlePopupReadyToShow handles the WebKit "ready-to-show" signal. | |
| 466 | - | // The popup WebView was already inserted into the workspace (hidden) during | |
| 467 | - | // the create signal. This handler just makes it visible. | |
| 468 | - | func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) { | |
| 469 | - | log := logging.FromContext(ctx) | |
| 470 | - | ||
| 471 | - | // Get pending popup | |
| 472 | - | c.popupMu.Lock() | |
| 473 | - | pending, ok := c.pendingPopups[popupID] | |
| 474 | - | if ok { | |
| 475 | - | delete(c.pendingPopups, popupID) | |
| 476 | - | } | |
| 477 | - | c.popupMu.Unlock() | |
| 478 | - | ||
| 479 | - | if !ok || pending == nil { | |
| 480 | - | log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup") | |
| 481 | - | return | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | log.Debug(). | |
| 485 | - | Uint64("popup_id", uint64(popupID)). | |
| 486 | - | Str("popup_type", pending.PopupType.String()). | |
| 487 | - | Msg("popup ready to show - making visible") | |
| 488 | - | ||
| 489 | - | // Make the WebView visible now that it's ready. | |
| 490 | - | if pending.WebView != nil { | |
| 491 | - | if pc, ok := pending.WebView.(port.PopupCapable); ok { | |
| 492 | - | pc.Show() | |
| 493 | - | } | |
| 494 | - | // For engines that create independent browsers (not real popups), | |
| 495 | - | // the WebView has no pending navigation — load the target URI. | |
| 496 | - | if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" { | |
| 497 | - | if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil { | |
| 498 | - | log.Warn().Err(err). | |
| 499 | - | Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)). | |
| 500 | - | Msg("failed to load target URI in popup") | |
| 501 | - | } | |
| 502 | - | } | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | log.Info(). | |
| 506 | - | Uint64("popup_id", uint64(popupID)). | |
| 507 | - | Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)). | |
| 508 | - | Msg("popup now visible") | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | // handlePopupClose handles the WebKit "close" signal for popup windows. | |
| 512 | - | func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) { | |
| 513 | - | log := logging.FromContext(ctx) | |
| 514 | - | log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received") | |
| 515 | - | ||
| 516 | - | // Check if still pending (never shown) | |
| 517 | - | c.popupMu.Lock() | |
| 518 | - | pending, wasPending := c.pendingPopups[popupID] | |
| 519 | - | if wasPending { | |
| 520 | - | delete(c.pendingPopups, popupID) | |
| 521 | - | } | |
| 522 | - | c.popupMu.Unlock() | |
| 523 | - | ||
| 524 | - | if wasPending && pending != nil { | |
| 525 | - | if c.onClosePane != nil { | |
| 526 | - | if err := c.onClosePane(ctx, pending.PaneID); err != nil { | |
| 527 | - | log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane") | |
| 528 | - | } | |
| 529 | - | } | |
| 530 | - | c.handlePopupOAuthClose(ctx, popupID) | |
| 531 | - | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 532 | - | c.ReleaseWebView(ctx, pending.PaneID) | |
| 533 | - | log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown") | |
| 534 | - | return | |
| 535 | - | } | |
| 536 | - | ||
| 537 | - | // Find pane by WebView ID | |
| 538 | - | var paneID entity.PaneID | |
| 539 | - | c.webViewsMu.RLock() | |
| 540 | - | for pid, wv := range c.webViews { | |
| 541 | - | if wv != nil && wv.ID() == popupID { | |
| 542 | - | paneID = pid | |
| 543 | - | break | |
| 544 | - | } | |
| 545 | - | } | |
| 546 | - | c.webViewsMu.RUnlock() | |
| 547 | - | ||
| 548 | - | if paneID == "" { | |
| 549 | - | c.handlePopupOAuthClose(ctx, popupID) | |
| 550 | - | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 551 | - | log.Warn().Msg("popup close: could not find pane for webview") | |
| 552 | - | return | |
| 553 | - | } | |
| 554 | - | ||
| 555 | - | // Close the pane in workspace (this removes the UI element) | |
| 556 | - | if c.onClosePane != nil { | |
| 557 | - | if err := c.onClosePane(ctx, paneID); err != nil { | |
| 558 | - | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane") | |
| 559 | - | } | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | c.handlePopupOAuthClose(ctx, popupID) | |
| 563 | - | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 564 | - | ||
| 565 | - | // Release the WebView (this will clean up tracking) | |
| 566 | - | c.ReleaseWebView(ctx, paneID) | |
| 567 | - | ||
| 568 | - | log.Info().Str("pane_id", string(paneID)).Msg("popup closed") | |
| 194 | + | return c.ensurePopupManager().handlePopupCreate(ctx, c.popupHooks(), parentPaneID, parentWV, req) | |
| 569 | 195 | } | |
| 570 | 196 | ||
| 571 | 197 | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | |
| 572 | 198 | // Opens the link in a new pane using blank_target_behavior config. | |
| 573 | 199 | func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool { | |
| 574 | - | log := logging.FromContext(ctx) | |
| 575 | - | ||
| 576 | - | log.Info(). | |
| 577 | - | Str("parent_pane", string(parentPaneID)). | |
| 578 | - | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 579 | - | Msg("middle-click/ctrl+click on link") | |
| 580 | - | ||
| 581 | - | // Check if popups are enabled | |
| 582 | - | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |
| 583 | - | log.Debug().Msg("popups disabled by config, ignoring middle-click") | |
| 584 | - | return false | |
| 585 | - | } | |
| 586 | - | ||
| 587 | - | // Check if factory is available | |
| 588 | - | if c.factory == nil { | |
| 589 | - | log.Warn().Msg("no webview factory, cannot handle middle-click") | |
| 590 | - | return false | |
| 591 | - | } | |
| 592 | - | ||
| 593 | - | // Create a new WebView (regular, not related - just opening a link) | |
| 594 | - | newWV, err := c.factory.Create(ctx) | |
| 595 | - | if err != nil { | |
| 596 | - | log.Error().Err(err).Msg("failed to create webview for middle-click") | |
| 597 | - | return false | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | // Generate pane ID | |
| 601 | - | var paneID entity.PaneID | |
| 602 | - | if c.generateID != nil { | |
| 603 | - | paneID = entity.PaneID(c.generateID()) | |
| 604 | - | } else { | |
| 605 | - | paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID())) | |
| 606 | - | } | |
| 607 | - | ||
| 608 | - | // Create pane entity - uses blank_target_behavior | |
| 609 | - | newPane := entity.NewPane(paneID) | |
| 610 | - | newPane.WindowType = entity.WindowPopup | |
| 611 | - | newPane.URI = uri | |
| 612 | - | ||
| 613 | - | // Register WebView | |
| 614 | - | c.setWebViewLocked(paneID, newWV) | |
| 615 | - | ||
| 616 | - | // Setup callbacks | |
| 617 | - | c.setupWebViewCallbacks(ctx, paneID, newWV) | |
| 618 | - | ||
| 619 | - | // Get behavior from config (same as _blank links) | |
| 620 | - | behavior := entity.PopupBehaviorStacked // default | |
| 621 | - | if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" { | |
| 622 | - | behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior) | |
| 623 | - | } | |
| 624 | - | placement := "right" | |
| 625 | - | if c.popupConfig != nil { | |
| 626 | - | placement = c.popupConfig.Placement | |
| 627 | - | } | |
| 628 | - | ||
| 629 | - | // Request insertion into workspace | |
| 630 | - | if c.onInsertPopup != nil { | |
| 631 | - | popupInput := InsertPopupInput{ | |
| 632 | - | ParentPaneID: parentPaneID, | |
| 633 | - | PopupPane: newPane, | |
| 634 | - | WebView: newWV, | |
| 635 | - | Behavior: behavior, | |
| 636 | - | Placement: placement, | |
| 637 | - | PopupType: PopupTypeTab, // Treat like _blank | |
| 638 | - | TargetURI: uri, | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | if err := c.onInsertPopup(ctx, popupInput); err != nil { | |
| 642 | - | log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") | |
| 643 | - | // Clean up on failure — use the locking helper to avoid a data race. | |
| 644 | - | c.deleteWebViewLocked(paneID) | |
| 645 | - | newWV.Destroy() | |
| 646 | - | return false | |
| 647 | - | } | |
| 648 | - | } | |
| 649 | - | ||
| 650 | - | // Load the URI after insertion | |
| 651 | - | if err := newWV.LoadURI(ctx, uri); err != nil { | |
| 652 | - | log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane") | |
| 653 | - | } | |
| 654 | - | ||
| 655 | - | log.Info(). | |
| 656 | - | Str("pane_id", string(paneID)). | |
| 657 | - | Str("behavior", string(behavior)). | |
| 658 | - | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 659 | - | Msg("middle-click link opened in new pane") | |
| 660 | - | ||
| 661 | - | return true | |
| 200 | + | return c.ensurePopupManager().handleLinkMiddleClick(ctx, c.popupHooks(), parentPaneID, uri) | |
| 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -12,6 +13,7 @@ import ( | |||
| 12 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 14 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 14 | 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 | 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 | 445 | func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) { | |
| 129 | 446 | ctx := context.Background() | |
| 130 | 447 | parentPaneID := entity.PaneID("parent-pane") | |
| @@ -153,9 +470,8 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) { | |||
| 153 | 470 | ||
| 154 | 471 | insertCalls := 0 | |
| 155 | 472 | c := &Coordinator{ | |
| 156 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 157 | - | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 158 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 473 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 474 | + | popups: newPopupManager(), | |
| 159 | 475 | } | |
| 160 | 476 | c.SetPopupConfig(factory, nil, nil) | |
| 161 | 477 | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| @@ -189,22 +505,23 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) { | |||
| 189 | 505 | t.Parallel() | |
| 190 | 506 | ||
| 191 | 507 | c := &Coordinator{ | |
| 192 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 193 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 508 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 509 | + | popups: newPopupManager(), | |
| 194 | 510 | } | |
| 195 | 511 | ||
| 196 | 512 | popupID := port.WebViewID(1) | |
| 197 | 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() | |
| 202 | - | state, ok := c.popupOAuth[popupID] | |
| 203 | - | c.popupMu.RUnlock() | |
| 517 | + | c.popups.mu.RLock() | |
| 518 | + | state, ok := c.popups.popupOAuth[popupID] | |
| 519 | + | c.popups.mu.RUnlock() | |
| 204 | 520 | ||
| 205 | 521 | require.True(t, ok, "oauth state should be tracked after trackOAuthPopup") | |
| 206 | 522 | require.NotNil(t, state) | |
| 207 | 523 | assert.Equal(t, parentPaneID, state.ParentPaneID) | |
| 524 | + | assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen) | |
| 208 | 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 | 529 | t.Parallel() | |
| 213 | 530 | ||
| 214 | 531 | c := &Coordinator{ | |
| 215 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 216 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 532 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 533 | + | popups: newPopupManager(), | |
| 217 | 534 | } | |
| 218 | 535 | ||
| 219 | 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 | 538 | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123") | |
| 222 | 539 | ||
| 223 | - | c.popupMu.RLock() | |
| 224 | - | state := c.popupOAuth[popupID] | |
| 225 | - | c.popupMu.RUnlock() | |
| 540 | + | c.popups.mu.RLock() | |
| 541 | + | state := c.popups.popupOAuth[popupID] | |
| 542 | + | c.popups.mu.RUnlock() | |
| 226 | 543 | ||
| 227 | 544 | require.NotNil(t, state) | |
| 228 | 545 | assert.True(t, state.Seen) | |
| @@ -235,17 +552,17 @@ func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) { | |||
| 235 | 552 | t.Parallel() | |
| 236 | 553 | ||
| 237 | 554 | c := &Coordinator{ | |
| 238 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 239 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 555 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 556 | + | popups: newPopupManager(), | |
| 240 | 557 | } | |
| 241 | 558 | ||
| 242 | 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 | 561 | c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied") | |
| 245 | 562 | ||
| 246 | - | c.popupMu.RLock() | |
| 247 | - | state := c.popupOAuth[popupID] | |
| 248 | - | c.popupMu.RUnlock() | |
| 563 | + | c.popups.mu.RLock() | |
| 564 | + | state := c.popups.popupOAuth[popupID] | |
| 565 | + | c.popups.mu.RUnlock() | |
| 249 | 566 | ||
| 250 | 567 | require.NotNil(t, state) | |
| 251 | 568 | assert.True(t, state.Seen) | |
| @@ -257,16 +574,16 @@ func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) { | |||
| 257 | 574 | t.Parallel() | |
| 258 | 575 | ||
| 259 | 576 | c := &Coordinator{ | |
| 260 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 261 | - | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 577 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 578 | + | popups: newPopupManager(), | |
| 262 | 579 | } | |
| 263 | 580 | ||
| 264 | 581 | // Should not panic when no tracking entry exists. | |
| 265 | 582 | c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x") | |
| 266 | 583 | ||
| 267 | - | c.popupMu.RLock() | |
| 268 | - | _, ok := c.popupOAuth[port.WebViewID(999)] | |
| 269 | - | c.popupMu.RUnlock() | |
| 584 | + | c.popups.mu.RLock() | |
| 585 | + | _, ok := c.popups.popupOAuth[port.WebViewID(999)] | |
| 586 | + | c.popups.mu.RUnlock() | |
| 270 | 587 | ||
| 271 | 588 | assert.False(t, ok) | |
| 272 | 589 | } | |
| @@ -286,10 +603,10 @@ func TestSetPopupConfig_SetsFields(t *testing.T) { | |||
| 286 | 603 | ||
| 287 | 604 | c.SetPopupConfig(factory, cfg, genID) | |
| 288 | 605 | ||
| 289 | - | assert.Equal(t, factory, c.factory) | |
| 290 | - | assert.Equal(t, cfg, c.popupConfig) | |
| 291 | - | assert.NotNil(t, c.generateID) | |
| 292 | - | assert.Equal(t, "test-id", c.generateID()) | |
| 606 | + | assert.Equal(t, factory, c.popups.factory) | |
| 607 | + | assert.Equal(t, cfg, c.popups.popupConfig) | |
| 608 | + | assert.NotNil(t, c.popups.generatePaneID) | |
| 609 | + | assert.Equal(t, "test-id", c.popups.generatePaneID()) | |
| 293 | 610 | } | |
| 294 | 611 | ||
| 295 | 612 | func TestSetPopupConfig_NilConfigAllowed(t *testing.T) { | |
| @@ -298,10 +615,39 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) { | |||
| 298 | 615 | c := &Coordinator{} | |
| 299 | 616 | c.SetPopupConfig(nil, nil, nil) | |
| 300 | 617 | ||
| 301 | - | assert.Nil(t, c.factory) | |
| 302 | - | assert.Nil(t, c.popupConfig) | |
| 303 | - | assert.Nil(t, c.generateID) | |
| 618 | + | assert.Nil(t, c.popups.factory) | |
| 619 | + | assert.Nil(t, c.popups.popupConfig) | |
| 620 | + | assert.Nil(t, c.popups.generatePaneID) | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | type popupNavigationWebViewStub struct { | |
| 624 | + | *mocks.MockWebView | |
| 625 | + | primed []string | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) { | |
| 629 | + | s.primed = append(s.primed, uri) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {} | |
| 633 | + | func (*popupNavigationWebViewStub) SetOnClose(func()) {} | |
| 634 | + | func (*popupNavigationWebViewStub) Show() {} | |
| 635 | + | ||
| 636 | + | type popupOpenerBridgeWebViewStub struct { | |
| 637 | + | *mocks.MockWebView | |
| 638 | + | parent port.WebView | |
| 639 | + | noJavaScriptAccess bool | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) { | |
| 643 | + | s.parent = parent | |
| 644 | + | s.noJavaScriptAccess = noJavaScriptAccess | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {} | |
| 648 | + | func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) { | |
| 304 | 649 | } | |
| 650 | + | func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false } | |
| 305 | 651 | ||
| 306 | 652 | // stubFactory satisfies port.WebViewFactory without importing the mocks package. | |
| 307 | 653 | type stubFactory struct{} | |
| @@ -322,7 +668,7 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) { | |||
| 322 | 668 | t.Parallel() | |
| 323 | 669 | ||
| 324 | 670 | c := &Coordinator{ | |
| 325 | - | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 671 | + | popups: newPopupManager(), | |
| 326 | 672 | } | |
| 327 | 673 | ||
| 328 | 674 | const workers = 20 | |
| @@ -334,12 +680,12 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) { | |||
| 334 | 680 | go func(id int) { | |
| 335 | 681 | defer wg.Done() | |
| 336 | 682 | popupID := port.WebViewID(id) | |
| 337 | - | c.popupMu.Lock() | |
| 338 | - | c.pendingPopups[popupID] = &PendingPopup{ | |
| 683 | + | c.popups.mu.Lock() | |
| 684 | + | c.popups.pendingPopups[popupID] = &PendingPopup{ | |
| 339 | 685 | FrameName: "_blank", | |
| 340 | 686 | PopupType: PopupTypeTab, | |
| 341 | 687 | } | |
| 342 | - | c.popupMu.Unlock() | |
| 688 | + | c.popups.mu.Unlock() | |
| 343 | 689 | }(i) | |
| 344 | 690 | } | |
| 345 | 691 | ||
| @@ -348,17 +694,17 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) { | |||
| 348 | 694 | go func(id int) { | |
| 349 | 695 | defer wg.Done() | |
| 350 | 696 | popupID := port.WebViewID(id) | |
| 351 | - | c.popupMu.RLock() | |
| 352 | - | _ = c.pendingPopups[popupID] | |
| 353 | - | c.popupMu.RUnlock() | |
| 697 | + | c.popups.mu.RLock() | |
| 698 | + | _ = c.popups.pendingPopups[popupID] | |
| 699 | + | c.popups.mu.RUnlock() | |
| 354 | 700 | }(i) | |
| 355 | 701 | } | |
| 356 | 702 | ||
| 357 | 703 | wg.Wait() | |
| 358 | 704 | ||
| 359 | - | c.popupMu.RLock() | |
| 360 | - | count := len(c.pendingPopups) | |
| 361 | - | c.popupMu.RUnlock() | |
| 705 | + | c.popups.mu.RLock() | |
| 706 | + | count := len(c.popups.pendingPopups) | |
| 707 | + | c.popups.mu.RUnlock() | |
| 362 | 708 | ||
| 363 | 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 | 713 | t.Parallel() | |
| 368 | 714 | ||
| 369 | 715 | c := &Coordinator{ | |
| 370 | - | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 716 | + | popups: newPopupManager(), | |
| 371 | 717 | } | |
| 372 | 718 | ||
| 373 | 719 | // Pre-populate | |
| 374 | 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 | 724 | var wg sync.WaitGroup | |
| @@ -382,9 +728,9 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||
| 382 | 728 | for i := 0; i < 10; i++ { | |
| 383 | 729 | go func(id int) { | |
| 384 | 730 | defer wg.Done() | |
| 385 | - | c.popupMu.Lock() | |
| 386 | - | delete(c.pendingPopups, port.WebViewID(id)) | |
| 387 | - | c.popupMu.Unlock() | |
| 731 | + | c.popups.mu.Lock() | |
| 732 | + | delete(c.popups.pendingPopups, port.WebViewID(id)) | |
| 733 | + | c.popups.mu.Unlock() | |
| 388 | 734 | }(i) | |
| 389 | 735 | } | |
| 390 | 736 | ||
| @@ -392,15 +738,15 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||
| 392 | 738 | for i := 0; i < 10; i++ { | |
| 393 | 739 | go func(id int) { | |
| 394 | 740 | defer wg.Done() | |
| 395 | - | c.popupMu.RLock() | |
| 396 | - | _ = c.pendingPopups[port.WebViewID(id)] | |
| 397 | - | c.popupMu.RUnlock() | |
| 741 | + | c.popups.mu.RLock() | |
| 742 | + | _ = c.popups.pendingPopups[port.WebViewID(id)] | |
| 743 | + | c.popups.mu.RUnlock() | |
| 398 | 744 | }(i) | |
| 399 | 745 | } | |
| 400 | 746 | ||
| 401 | 747 | wg.Wait() | |
| 402 | 748 | ||
| 403 | - | c.popupMu.RLock() | |
| 404 | - | defer c.popupMu.RUnlock() | |
| 405 | - | assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted") | |
| 749 | + | c.popups.mu.RLock() | |
| 750 | + | defer c.popups.mu.RUnlock() | |
| 751 | + | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | |
| 406 | 752 | } | |