fix(engine): route floating browsing contexts by features
- commit
- 8e762812710e998f2d8fd04e25e075f8b4261ab3
- author
- brice · · 2026-07-25 08:02 UTC
- committer
- brice · · 2026-07-26 16:42 UTC
- parent
- c5be254
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +971 -25
M internal/infrastructure/cef/webview.go +11 -3
| @@ -823,15 +823,23 @@ func (wv *WebView) SetNativePopupHostAbort(fn func()) { | |||
| 823 | 823 | } | |
| 824 | 824 | ||
| 825 | 825 | func (wv *WebView) AbortNativePopupHost() { | |
| 826 | + | wv.abortNativePopupHost() | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | // abortNativePopupHost reports whether an installed host callback took | |
| 830 | + | // responsibility for either transferring or destroying the popup WebView. | |
| 831 | + | func (wv *WebView) abortNativePopupHost() bool { | |
| 826 | 832 | if wv == nil { | |
| 827 | - | return | |
| 833 | + | return false | |
| 828 | 834 | } | |
| 829 | 835 | wv.mu.RLock() | |
| 830 | 836 | fn := wv.nativePopupHostAbort | |
| 831 | 837 | wv.mu.RUnlock() | |
| 832 | - | if fn != nil { | |
| 833 | - | fn() | |
| 838 | + | if fn == nil { | |
| 839 | + | return false | |
| 834 | 840 | } | |
| 841 | + | fn() | |
| 842 | + | return true | |
| 835 | 843 | } | |
| 836 | 844 | ||
| 837 | 845 | func (wv *WebView) awaitsNativePopupAttachment() bool { | |
| @@ -823,15 +823,23 @@ func (wv *WebView) SetNativePopupHostAbort(fn func()) { | |||||
| 823 | } | 823 | } | ||
| 824 | 824 | ||||
| 825 | func (wv *WebView) AbortNativePopupHost() { | 825 | func (wv *WebView) AbortNativePopupHost() { | ||
| 826 | + | wv.abortNativePopupHost() | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | // abortNativePopupHost reports whether an installed host callback took | |||
| 830 | + | // responsibility for either transferring or destroying the popup WebView. | |||
| 831 | + | func (wv *WebView) abortNativePopupHost() bool { | |||
| 826 | if wv == nil { | 832 | if wv == nil { | ||
| 827 | - | return | 833 | + | return false |
| 828 | } | 834 | } | ||
| 829 | wv.mu.RLock() | 835 | wv.mu.RLock() | ||
| 830 | fn := wv.nativePopupHostAbort | 836 | fn := wv.nativePopupHostAbort | ||
| 831 | wv.mu.RUnlock() | 837 | wv.mu.RUnlock() | ||
| 832 | - | if fn != nil { | 838 | + | if fn == nil { |
| 833 | - | fn() | 839 | + | return false |
| 834 | } | 840 | } | ||
| 841 | + | fn() | |||
| 842 | + | return true | |||
| 835 | } | 843 | } | ||
| 836 | 844 | ||||
| 837 | func (wv *WebView) awaitsNativePopupAttachment() bool { | 845 | func (wv *WebView) awaitsNativePopupAttachment() bool { | ||
M internal/infrastructure/cef/webview_handlers.go +58 -12
| @@ -587,6 +587,30 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCo | |||
| 587 | 587 | } | |
| 588 | 588 | } | |
| 589 | 589 | ||
| 590 | + | func mapCEFPopupFeatures(in *purecef.PopupFeatures) dto.PopupFeatures { | |
| 591 | + | if in == nil { | |
| 592 | + | return dto.PopupFeatures{State: dto.PopupFeaturesNone} | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | state := dto.PopupFeaturesNone | |
| 596 | + | if in.Xset != 0 || in.Yset != 0 || in.Widthset != 0 || in.Heightset != 0 || in.Ispopup != 0 { | |
| 597 | + | state = dto.PopupFeaturesSpecified | |
| 598 | + | } | |
| 599 | + | return dto.PopupFeatures{ | |
| 600 | + | State: state, | |
| 601 | + | X: int(in.X), | |
| 602 | + | Y: int(in.Y), | |
| 603 | + | Width: int(in.Width), | |
| 604 | + | Height: int(in.Height), | |
| 605 | + | XSet: in.Xset != 0, | |
| 606 | + | YSet: in.Yset != 0, | |
| 607 | + | WidthSet: in.Widthset != 0, | |
| 608 | + | HeightSet: in.Heightset != 0, | |
| 609 | + | IsPopup: in.Ispopup != 0, | |
| 610 | + | IsPopupSet: true, | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | ||
| 590 | 614 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | |
| 591 | 615 | switch disposition { | |
| 592 | 616 | case purecef.WindowOpenDispositionWodCurrentTab: | |
| @@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind | |||
| 616 | 640 | // pane handle the navigation. | |
| 617 | 641 | func (h *handlerSet) OnBeforePopup( | |
| 618 | 642 | browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string, | |
| 619 | - | targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | |
| 643 | + | targetDisposition purecef.WindowOpenDisposition, userGesture int32, popupFeatures *purecef.PopupFeatures, | |
| 620 | 644 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | |
| 621 | 645 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | |
| 622 | 646 | ) bool { | |
| @@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup( | |||
| 649 | 673 | ||
| 650 | 674 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | |
| 651 | 675 | popup = cb.OnCreate(port.PopupRequest{ | |
| 676 | + | Engine: dto.BrowserEngineCEF, | |
| 677 | + | PopupFeatures: mapCEFPopupFeatures(popupFeatures), | |
| 652 | 678 | TargetURI: targetURL, | |
| 653 | 679 | FrameName: targetFrameName, | |
| 654 | 680 | IsUserGesture: userGesture != 0, | |
| @@ -692,32 +718,52 @@ func (h *handlerSet) OnBeforePopup( | |||
| 692 | 718 | Msg("cef: native popup armed") | |
| 693 | 719 | return false | |
| 694 | 720 | } | |
| 695 | - | if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok { | |
| 696 | - | aborter.AbortNativePopupHost() | |
| 721 | + | ||
| 722 | + | fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup | |
| 723 | + | if fallbackEligible { | |
| 724 | + | // The Phase 3 abort callback may synchronously detach and adopt this | |
| 725 | + | // shell. Arm direct creation first so the adopted shell can create a | |
| 726 | + | // browser and retain the synthetic opener bridge when allowed. | |
| 727 | + | cefPopup.preparePopupShellDirectBrowserCreation() | |
| 728 | + | } | |
| 729 | + | if !cefPopup.abortNativePopupHost() { | |
| 730 | + | cefPopup.Destroy() | |
| 697 | 731 | } | |
| 698 | - | cefPopup.discardNativePopupCandidate() | |
| 699 | 732 | logging.FromContext(h.currentContext()).Warn(). | |
| 700 | 733 | Int32("popup_id", popupID). | |
| 701 | 734 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 702 | - | Msg("cef: native popup arming failed, denying without fallback") | |
| 735 | + | Bool("fallback_eligible", fallbackEligible). | |
| 736 | + | Msg("cef: native popup arming failed and host abort was invoked") | |
| 703 | 737 | return true | |
| 704 | - | case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane: | |
| 738 | + | case dto.HostDecisionCreateBrowserWindow, | |
| 739 | + | dto.HostDecisionCreatePane, | |
| 740 | + | dto.HostDecisionReuseNamedPane: | |
| 741 | + | // CEF's native popup is blocked. A related shell adopted by a Dumber | |
| 742 | + | // host must create its browser directly; noopener controls whether the | |
| 743 | + | // synthetic opener/postMessage bridge is installed. | |
| 744 | + | cefPopup.preparePopupShellDirectBrowserCreation() | |
| 705 | 745 | logging.FromContext(h.currentContext()).Debug(). | |
| 706 | 746 | Int32("popup_id", popupID). | |
| 707 | 747 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 708 | 748 | Str("decision", string(decision.Kind)). | |
| 709 | - | Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation") | |
| 749 | + | Msg("cef: blocking native popup and using related shell browser creation") | |
| 750 | + | return true | |
| 751 | + | case dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionDeny: | |
| 752 | + | cefPopup.Destroy() | |
| 753 | + | logging.FromContext(h.currentContext()).Warn(). | |
| 754 | + | Int32("popup_id", popupID). | |
| 755 | + | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 756 | + | Str("decision", string(decision.Kind)). | |
| 757 | + | Msg("cef: denied popup host decision") | |
| 710 | 758 | return true | |
| 711 | 759 | default: | |
| 712 | - | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | |
| 713 | - | return false | |
| 760 | + | if !cefPopup.preparePopupShellDirectBrowserCreation() { | |
| 761 | + | cefPopup.discardNativePopupCandidate() | |
| 714 | 762 | } | |
| 715 | - | ||
| 716 | - | cefPopup.discardNativePopupCandidate() | |
| 717 | 763 | logging.FromContext(h.currentContext()).Debug(). | |
| 718 | 764 | Int32("popup_id", popupID). | |
| 719 | 765 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 720 | - | Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation") | |
| 766 | + | Msg("cef: missing host decision, blocking native popup and using popup shell browser creation") | |
| 721 | 767 | return true | |
| 722 | 768 | } | |
| 723 | 769 | } | |
| @@ -587,6 +587,30 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCo | |||||
| 587 | } | 587 | } | ||
| 588 | } | 588 | } | ||
| 589 | 589 | ||||
| 590 | + | func mapCEFPopupFeatures(in *purecef.PopupFeatures) dto.PopupFeatures { | |||
| 591 | + | if in == nil { | |||
| 592 | + | return dto.PopupFeatures{State: dto.PopupFeaturesNone} | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | state := dto.PopupFeaturesNone | |||
| 596 | + | if in.Xset != 0 || in.Yset != 0 || in.Widthset != 0 || in.Heightset != 0 || in.Ispopup != 0 { | |||
| 597 | + | state = dto.PopupFeaturesSpecified | |||
| 598 | + | } | |||
| 599 | + | return dto.PopupFeatures{ | |||
| 600 | + | State: state, | |||
| 601 | + | X: int(in.X), | |||
| 602 | + | Y: int(in.Y), | |||
| 603 | + | Width: int(in.Width), | |||
| 604 | + | Height: int(in.Height), | |||
| 605 | + | XSet: in.Xset != 0, | |||
| 606 | + | YSet: in.Yset != 0, | |||
| 607 | + | WidthSet: in.Widthset != 0, | |||
| 608 | + | HeightSet: in.Heightset != 0, | |||
| 609 | + | IsPopup: in.Ispopup != 0, | |||
| 610 | + | IsPopupSet: true, | |||
| 611 | + | } | |||
| 612 | + | } | |||
| 613 | + | ||||
| 590 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | 614 | func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition { | ||
| 591 | switch disposition { | 615 | switch disposition { | ||
| 592 | case purecef.WindowOpenDispositionWodCurrentTab: | 616 | case purecef.WindowOpenDispositionWodCurrentTab: | ||
| @@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind | |||||
| 616 | // pane handle the navigation. | 640 | // pane handle the navigation. | ||
| 617 | func (h *handlerSet) OnBeforePopup( | 641 | func (h *handlerSet) OnBeforePopup( | ||
| 618 | browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string, | 642 | browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string, | ||
| 619 | - | targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | 643 | + | targetDisposition purecef.WindowOpenDisposition, userGesture int32, popupFeatures *purecef.PopupFeatures, |
| 620 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | 644 | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | ||
| 621 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | 645 | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | ||
| 622 | ) bool { | 646 | ) bool { | ||
| @@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 649 | 673 | ||||
| 650 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | 674 | dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() { | ||
| 651 | popup = cb.OnCreate(port.PopupRequest{ | 675 | popup = cb.OnCreate(port.PopupRequest{ | ||
| 676 | + | Engine: dto.BrowserEngineCEF, | |||
| 677 | + | PopupFeatures: mapCEFPopupFeatures(popupFeatures), | |||
| 652 | TargetURI: targetURL, | 678 | TargetURI: targetURL, | ||
| 653 | FrameName: targetFrameName, | 679 | FrameName: targetFrameName, | ||
| 654 | IsUserGesture: userGesture != 0, | 680 | IsUserGesture: userGesture != 0, | ||
| @@ -692,32 +718,52 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 692 | Msg("cef: native popup armed") | 718 | Msg("cef: native popup armed") | ||
| 693 | return false | 719 | return false | ||
| 694 | } | 720 | } | ||
| 695 | - | if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok { | 721 | + | |
| 696 | - | aborter.AbortNativePopupHost() | 722 | + | fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup |
| 723 | + | if fallbackEligible { | |||
| 724 | + | // The Phase 3 abort callback may synchronously detach and adopt this | |||
| 725 | + | // shell. Arm direct creation first so the adopted shell can create a | |||
| 726 | + | // browser and retain the synthetic opener bridge when allowed. | |||
| 727 | + | cefPopup.preparePopupShellDirectBrowserCreation() | |||
| 728 | + | } | |||
| 729 | + | if !cefPopup.abortNativePopupHost() { | |||
| 730 | + | cefPopup.Destroy() | |||
| 697 | } | 731 | } | ||
| 698 | - | cefPopup.discardNativePopupCandidate() | |||
| 699 | logging.FromContext(h.currentContext()).Warn(). | 732 | logging.FromContext(h.currentContext()).Warn(). | ||
| 700 | Int32("popup_id", popupID). | 733 | Int32("popup_id", popupID). | ||
| 701 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 734 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | ||
| 702 | - | Msg("cef: native popup arming failed, denying without fallback") | 735 | + | Bool("fallback_eligible", fallbackEligible). |
| 736 | + | Msg("cef: native popup arming failed and host abort was invoked") | |||
| 703 | return true | 737 | return true | ||
| 704 | - | case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane: | 738 | + | case dto.HostDecisionCreateBrowserWindow, |
| 739 | + | dto.HostDecisionCreatePane, | |||
| 740 | + | dto.HostDecisionReuseNamedPane: | |||
| 741 | + | // CEF's native popup is blocked. A related shell adopted by a Dumber | |||
| 742 | + | // host must create its browser directly; noopener controls whether the | |||
| 743 | + | // synthetic opener/postMessage bridge is installed. | |||
| 744 | + | cefPopup.preparePopupShellDirectBrowserCreation() | |||
| 705 | logging.FromContext(h.currentContext()).Debug(). | 745 | logging.FromContext(h.currentContext()).Debug(). | ||
| 706 | Int32("popup_id", popupID). | 746 | Int32("popup_id", popupID). | ||
| 707 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 747 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | ||
| 708 | Str("decision", string(decision.Kind)). | 748 | Str("decision", string(decision.Kind)). | ||
| 709 | - | Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation") | 749 | + | Msg("cef: blocking native popup and using related shell browser creation") |
| 750 | + | return true | |||
| 751 | + | case dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionDeny: | |||
| 752 | + | cefPopup.Destroy() | |||
| 753 | + | logging.FromContext(h.currentContext()).Warn(). | |||
| 754 | + | Int32("popup_id", popupID). | |||
| 755 | + | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |||
| 756 | + | Str("decision", string(decision.Kind)). | |||
| 757 | + | Msg("cef: denied popup host decision") | |||
| 710 | return true | 758 | return true | ||
| 711 | default: | 759 | default: | ||
| 712 | - | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | 760 | + | if !cefPopup.preparePopupShellDirectBrowserCreation() { |
| 713 | - | return false | 761 | + | cefPopup.discardNativePopupCandidate() |
| 714 | } | 762 | } | ||
| 715 | - | ||||
| 716 | - | cefPopup.discardNativePopupCandidate() | |||
| 717 | logging.FromContext(h.currentContext()).Debug(). | 763 | logging.FromContext(h.currentContext()).Debug(). | ||
| 718 | Int32("popup_id", popupID). | 764 | Int32("popup_id", popupID). | ||
| 719 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | 765 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | ||
| 720 | - | Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation") | 766 | + | Msg("cef: missing host decision, blocking native popup and using popup shell browser creation") |
| 721 | return true | 767 | return true | ||
| 722 | } | 768 | } | ||
| 723 | } | 769 | } | ||
M internal/infrastructure/cef/webview_handlers_test.go +245 -0
| @@ -229,6 +229,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe | |||
| 229 | 229 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | |
| 230 | 230 | require.True(t, ok) | |
| 231 | 231 | require.NotNil(t, rawClient) | |
| 232 | + | require.Equal(t, int32(55), wv.nativePopupID) | |
| 233 | + | require.Same(t, parent, wv.nativePopupParent) | |
| 232 | 234 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | |
| 233 | 235 | ||
| 234 | 236 | wv.updateURI("about:blank") | |
| @@ -262,6 +264,249 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||
| 262 | 264 | require.True(t, wv.isLoading) | |
| 263 | 265 | } | |
| 264 | 266 | ||
| 267 | + | func TestMapCEFPopupFeatures(t *testing.T) { | |
| 268 | + | t.Parallel() | |
| 269 | + | ||
| 270 | + | tests := []struct { | |
| 271 | + | name string | |
| 272 | + | in *purecef.PopupFeatures | |
| 273 | + | want dto.PopupFeatures | |
| 274 | + | }{ | |
| 275 | + | { | |
| 276 | + | name: "nil is known featureless", | |
| 277 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 278 | + | }, | |
| 279 | + | { | |
| 280 | + | name: "positive width is popup-like", | |
| 281 | + | in: &purecef.PopupFeatures{Width: 640, Widthset: 1}, | |
| 282 | + | want: dto.PopupFeatures{ | |
| 283 | + | State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true, IsPopupSet: true, | |
| 284 | + | }, | |
| 285 | + | }, | |
| 286 | + | { | |
| 287 | + | name: "geometry and flags are normalized", | |
| 288 | + | in: &purecef.PopupFeatures{ | |
| 289 | + | X: 10, Xset: 1, Y: 20, Yset: 1, | |
| 290 | + | Width: 800, Widthset: 1, Height: 600, Heightset: 1, | |
| 291 | + | }, | |
| 292 | + | want: dto.PopupFeatures{ | |
| 293 | + | State: dto.PopupFeaturesSpecified, | |
| 294 | + | X: 10, XSet: true, Y: 20, YSet: true, | |
| 295 | + | Width: 800, WidthSet: true, Height: 600, HeightSet: true, | |
| 296 | + | IsPopupSet: true, | |
| 297 | + | }, | |
| 298 | + | }, | |
| 299 | + | { | |
| 300 | + | name: "is popup flag survives without inventing geometry", | |
| 301 | + | in: &purecef.PopupFeatures{Ispopup: 1}, | |
| 302 | + | want: dto.PopupFeatures{ | |
| 303 | + | State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true, | |
| 304 | + | }, | |
| 305 | + | }, | |
| 306 | + | { | |
| 307 | + | name: "allocated defaults are known featureless", | |
| 308 | + | in: &purecef.PopupFeatures{}, | |
| 309 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone, IsPopupSet: true}, | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | for _, tt := range tests { | |
| 314 | + | t.Run(tt.name, func(t *testing.T) { | |
| 315 | + | got := mapCEFPopupFeatures(tt.in) | |
| 316 | + | assert.Equal(t, tt.want, got) | |
| 317 | + | assert.Equal(t, tt.want.RequestsPopupHost(), got.RequestsPopupHost()) | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestOnBeforePopup_PropagatesCEFEngineFeaturesGestureAndNoJavaScriptAccess(t *testing.T) { | |
| 323 | + | parentWV := &WebView{ctx: context.Background(), id: 16} | |
| 324 | + | popupWV := &WebView{ctx: context.Background(), id: 24, pendingCreate: &pendingBrowserCreate{}} | |
| 325 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 326 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 327 | + | noJavaScriptAccess := true | |
| 328 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 329 | + | assert.Equal(t, dto.BrowserEngineCEF, req.Engine) | |
| 330 | + | assert.Equal(t, dto.PopupFeatures{ | |
| 331 | + | State: dto.PopupFeaturesSpecified, X: 10, XSet: true, Width: 640, WidthSet: true, | |
| 332 | + | IsPopup: true, IsPopupSet: true, | |
| 333 | + | }, req.PopupFeatures) | |
| 334 | + | assert.Equal(t, dto.WindowDispositionNewPopup, req.TargetDisposition) | |
| 335 | + | assert.True(t, req.IsUserGesture) | |
| 336 | + | assert.True(t, req.NoJavaScriptAccess) | |
| 337 | + | return popupWV | |
| 338 | + | }}) | |
| 339 | + | ||
| 340 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 341 | + | nil, nil, 90, "https://example.com/popup", "named-popup", | |
| 342 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 343 | + | &purecef.PopupFeatures{X: 10, Xset: 1, Width: 640, Widthset: 1, Ispopup: 1}, | |
| 344 | + | nil, nil, nil, nil, &noJavaScriptAccess, | |
| 345 | + | ) | |
| 346 | + | ||
| 347 | + | require.True(t, blocked) | |
| 348 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 349 | + | require.Nil(t, popupWV.popupOpenerBridgeParent, "noopener must not install the synthetic opener bridge") | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func TestOnBeforePopup_BrowserWindowDecisionPreservesSyntheticOpenerBridge(t *testing.T) { | |
| 353 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 354 | + | parentWV.updateURI("https://example.com/opener") | |
| 355 | + | popupWV := &WebView{ctx: context.Background(), id: 25, pendingCreate: &pendingBrowserCreate{}} | |
| 356 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 357 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |
| 358 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 359 | + | ||
| 360 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 361 | + | nil, nil, 91, "https://example.com/child", "child", | |
| 362 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 363 | + | ) | |
| 364 | + | ||
| 365 | + | require.True(t, blocked) | |
| 366 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 367 | + | require.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |
| 368 | + | require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *testing.T) { | |
| 372 | + | parentWV := &WebView{ctx: context.Background(), id: 18} | |
| 373 | + | parentWV.updateURI("https://example.com/opener") | |
| 374 | + | popupWV := &WebView{ctx: context.Background(), id: 26, pendingCreate: &pendingBrowserCreate{}} | |
| 375 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 376 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 377 | + | abortCalls := 0 | |
| 378 | + | popupWV.SetNativePopupHostAbort(func() { | |
| 379 | + | abortCalls++ | |
| 380 | + | assert.True(t, popupWV.nativePopupFallbackStarted) | |
| 381 | + | assert.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |
| 382 | + | }) | |
| 383 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 384 | + | ||
| 385 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 386 | + | nil, nil, 92, "https://example.com/visual", "visual", | |
| 387 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 388 | + | &purecef.PopupFeatures{Width: 640, Widthset: 1}, nil, nil, nil, nil, nil, | |
| 389 | + | ) | |
| 390 | + | ||
| 391 | + | require.True(t, blocked) | |
| 392 | + | require.Equal(t, 1, abortCalls) | |
| 393 | + | require.False(t, popupWV.IsDestroyed()) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |
| 397 | + | parentWV := &WebView{ctx: context.Background(), id: 19} | |
| 398 | + | popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}} | |
| 399 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 400 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 401 | + | Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true, | |
| 402 | + | }) | |
| 403 | + | abortCalls := 0 | |
| 404 | + | popupWV.SetNativePopupHostAbort(func() { | |
| 405 | + | abortCalls++ | |
| 406 | + | popupWV.Destroy() | |
| 407 | + | }) | |
| 408 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 409 | + | ||
| 410 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 411 | + | nil, nil, 93, "https://example.com/opener-required", "required", | |
| 412 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 413 | + | ) | |
| 414 | + | ||
| 415 | + | require.True(t, blocked) | |
| 416 | + | require.Equal(t, 1, abortCalls) | |
| 417 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 418 | + | require.Nil(t, popupWV.popupOpenerBridgeParent) | |
| 419 | + | require.True(t, popupWV.IsDestroyed()) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |
| 423 | + | parentWV := &WebView{ctx: context.Background(), id: 20} | |
| 424 | + | popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}} | |
| 425 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 426 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 427 | + | Kind: dto.HostDecisionCreateNativePopup, ReasonCode: dto.HostDecisionReasonAuthNativePopup, | |
| 428 | + | }) | |
| 429 | + | popupWV.SetNativePopupHostAbort(func() { popupWV.Destroy() }) | |
| 430 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 431 | + | ||
| 432 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 433 | + | nil, nil, 94, "https://accounts.example.test/oauth", "oauth", | |
| 434 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 435 | + | ) | |
| 436 | + | ||
| 437 | + | require.True(t, blocked) | |
| 438 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 439 | + | require.True(t, popupWV.IsDestroyed()) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func TestOnBeforePopup_IsPopupFlagCanSelectNativePopup(t *testing.T) { | |
| 443 | + | parentWV := &WebView{ctx: context.Background(), id: 22} | |
| 444 | + | popupWV := &WebView{ctx: context.Background(), id: 30, pendingCreate: &pendingBrowserCreate{}} | |
| 445 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 446 | + | abortCalls := 0 | |
| 447 | + | popupWV.SetNativePopupHostAbort(func() { abortCalls++ }) | |
| 448 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |
| 449 | + | require.True(t, req.PopupFeatures.RequestsPopupHost()) | |
| 450 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 451 | + | return popupWV | |
| 452 | + | }}) | |
| 453 | + | ||
| 454 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 455 | + | nil, nil, 96, "https://example.com/chrome-restricted", "restricted", | |
| 456 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |
| 457 | + | &purecef.PopupFeatures{Ispopup: 1}, nil, nil, nil, nil, nil, | |
| 458 | + | ) | |
| 459 | + | ||
| 460 | + | require.True(t, blocked) | |
| 461 | + | require.Equal(t, 1, abortCalls) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestOnBeforePopup_ReuseNamedPaneBlocksNativePopup(t *testing.T) { | |
| 465 | + | parentWV := &WebView{ctx: context.Background(), id: 23} | |
| 466 | + | popupWV := &WebView{ctx: context.Background(), id: 31, pendingCreate: &pendingBrowserCreate{}} | |
| 467 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 468 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionReuseNamedPane}) | |
| 469 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 470 | + | ||
| 471 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 472 | + | nil, nil, 97, "https://example.com/named", "shared", | |
| 473 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 474 | + | ) | |
| 475 | + | ||
| 476 | + | require.True(t, blocked) | |
| 477 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func TestOnBeforePopup_DenyCleansUp(t *testing.T) { | |
| 481 | + | parentWV := &WebView{ctx: context.Background(), id: 24} | |
| 482 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 483 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionDeny}) | |
| 484 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 485 | + | ||
| 486 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 487 | + | nil, nil, 98, "https://example.com/denied", "denied", | |
| 488 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 489 | + | ) | |
| 490 | + | ||
| 491 | + | require.True(t, blocked) | |
| 492 | + | require.True(t, popupWV.IsDestroyed()) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func TestOnBeforePopup_AwaitFeaturesIsImpossibleAndCleansUp(t *testing.T) { | |
| 496 | + | parentWV := &WebView{ctx: context.Background(), id: 21} | |
| 497 | + | popupWV := &WebView{ctx: context.Background(), id: 29} | |
| 498 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionAwaitPopupFeatures}) | |
| 499 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |
| 500 | + | ||
| 501 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |
| 502 | + | nil, nil, 95, "https://example.com/impossible", "impossible", | |
| 503 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |
| 504 | + | ) | |
| 505 | + | ||
| 506 | + | require.True(t, blocked) | |
| 507 | + | require.True(t, popupWV.IsDestroyed()) | |
| 508 | + | } | |
| 509 | + | ||
| 265 | 510 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |
| 266 | 511 | delayed := make(chan func(), 1) | |
| 267 | 512 | parentWV := &WebView{ | |
| @@ -229,6 +229,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe | |||||
| 229 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | 229 | rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth") | ||
| 230 | require.True(t, ok) | 230 | require.True(t, ok) | ||
| 231 | require.NotNil(t, rawClient) | 231 | require.NotNil(t, rawClient) | ||
| 232 | + | require.Equal(t, int32(55), wv.nativePopupID) | |||
| 233 | + | require.Same(t, parent, wv.nativePopupParent) | |||
| 232 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | 234 | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | ||
| 233 | 235 | ||||
| 234 | wv.updateURI("about:blank") | 236 | wv.updateURI("about:blank") | ||
| @@ -262,6 +264,249 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||||
| 262 | require.True(t, wv.isLoading) | 264 | require.True(t, wv.isLoading) | ||
| 263 | } | 265 | } | ||
| 264 | 266 | ||||
| 267 | + | func TestMapCEFPopupFeatures(t *testing.T) { | |||
| 268 | + | t.Parallel() | |||
| 269 | + | ||||
| 270 | + | tests := []struct { | |||
| 271 | + | name string | |||
| 272 | + | in *purecef.PopupFeatures | |||
| 273 | + | want dto.PopupFeatures | |||
| 274 | + | }{ | |||
| 275 | + | { | |||
| 276 | + | name: "nil is known featureless", | |||
| 277 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |||
| 278 | + | }, | |||
| 279 | + | { | |||
| 280 | + | name: "positive width is popup-like", | |||
| 281 | + | in: &purecef.PopupFeatures{Width: 640, Widthset: 1}, | |||
| 282 | + | want: dto.PopupFeatures{ | |||
| 283 | + | State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true, IsPopupSet: true, | |||
| 284 | + | }, | |||
| 285 | + | }, | |||
| 286 | + | { | |||
| 287 | + | name: "geometry and flags are normalized", | |||
| 288 | + | in: &purecef.PopupFeatures{ | |||
| 289 | + | X: 10, Xset: 1, Y: 20, Yset: 1, | |||
| 290 | + | Width: 800, Widthset: 1, Height: 600, Heightset: 1, | |||
| 291 | + | }, | |||
| 292 | + | want: dto.PopupFeatures{ | |||
| 293 | + | State: dto.PopupFeaturesSpecified, | |||
| 294 | + | X: 10, XSet: true, Y: 20, YSet: true, | |||
| 295 | + | Width: 800, WidthSet: true, Height: 600, HeightSet: true, | |||
| 296 | + | IsPopupSet: true, | |||
| 297 | + | }, | |||
| 298 | + | }, | |||
| 299 | + | { | |||
| 300 | + | name: "is popup flag survives without inventing geometry", | |||
| 301 | + | in: &purecef.PopupFeatures{Ispopup: 1}, | |||
| 302 | + | want: dto.PopupFeatures{ | |||
| 303 | + | State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true, | |||
| 304 | + | }, | |||
| 305 | + | }, | |||
| 306 | + | { | |||
| 307 | + | name: "allocated defaults are known featureless", | |||
| 308 | + | in: &purecef.PopupFeatures{}, | |||
| 309 | + | want: dto.PopupFeatures{State: dto.PopupFeaturesNone, IsPopupSet: true}, | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | for _, tt := range tests { | |||
| 314 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 315 | + | got := mapCEFPopupFeatures(tt.in) | |||
| 316 | + | assert.Equal(t, tt.want, got) | |||
| 317 | + | assert.Equal(t, tt.want.RequestsPopupHost(), got.RequestsPopupHost()) | |||
| 318 | + | }) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | func TestOnBeforePopup_PropagatesCEFEngineFeaturesGestureAndNoJavaScriptAccess(t *testing.T) { | |||
| 323 | + | parentWV := &WebView{ctx: context.Background(), id: 16} | |||
| 324 | + | popupWV := &WebView{ctx: context.Background(), id: 24, pendingCreate: &pendingBrowserCreate{}} | |||
| 325 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 326 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |||
| 327 | + | noJavaScriptAccess := true | |||
| 328 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |||
| 329 | + | assert.Equal(t, dto.BrowserEngineCEF, req.Engine) | |||
| 330 | + | assert.Equal(t, dto.PopupFeatures{ | |||
| 331 | + | State: dto.PopupFeaturesSpecified, X: 10, XSet: true, Width: 640, WidthSet: true, | |||
| 332 | + | IsPopup: true, IsPopupSet: true, | |||
| 333 | + | }, req.PopupFeatures) | |||
| 334 | + | assert.Equal(t, dto.WindowDispositionNewPopup, req.TargetDisposition) | |||
| 335 | + | assert.True(t, req.IsUserGesture) | |||
| 336 | + | assert.True(t, req.NoJavaScriptAccess) | |||
| 337 | + | return popupWV | |||
| 338 | + | }}) | |||
| 339 | + | ||||
| 340 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 341 | + | nil, nil, 90, "https://example.com/popup", "named-popup", | |||
| 342 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |||
| 343 | + | &purecef.PopupFeatures{X: 10, Xset: 1, Width: 640, Widthset: 1, Ispopup: 1}, | |||
| 344 | + | nil, nil, nil, nil, &noJavaScriptAccess, | |||
| 345 | + | ) | |||
| 346 | + | ||||
| 347 | + | require.True(t, blocked) | |||
| 348 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |||
| 349 | + | require.Nil(t, popupWV.popupOpenerBridgeParent, "noopener must not install the synthetic opener bridge") | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func TestOnBeforePopup_BrowserWindowDecisionPreservesSyntheticOpenerBridge(t *testing.T) { | |||
| 353 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |||
| 354 | + | parentWV.updateURI("https://example.com/opener") | |||
| 355 | + | popupWV := &WebView{ctx: context.Background(), id: 25, pendingCreate: &pendingBrowserCreate{}} | |||
| 356 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 357 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow}) | |||
| 358 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 359 | + | ||||
| 360 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 361 | + | nil, nil, 91, "https://example.com/child", "child", | |||
| 362 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 363 | + | ) | |||
| 364 | + | ||||
| 365 | + | require.True(t, blocked) | |||
| 366 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |||
| 367 | + | require.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |||
| 368 | + | require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI) | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *testing.T) { | |||
| 372 | + | parentWV := &WebView{ctx: context.Background(), id: 18} | |||
| 373 | + | parentWV.updateURI("https://example.com/opener") | |||
| 374 | + | popupWV := &WebView{ctx: context.Background(), id: 26, pendingCreate: &pendingBrowserCreate{}} | |||
| 375 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 376 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |||
| 377 | + | abortCalls := 0 | |||
| 378 | + | popupWV.SetNativePopupHostAbort(func() { | |||
| 379 | + | abortCalls++ | |||
| 380 | + | assert.True(t, popupWV.nativePopupFallbackStarted) | |||
| 381 | + | assert.Same(t, parentWV, popupWV.popupOpenerBridgeParent) | |||
| 382 | + | }) | |||
| 383 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 384 | + | ||||
| 385 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 386 | + | nil, nil, 92, "https://example.com/visual", "visual", | |||
| 387 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |||
| 388 | + | &purecef.PopupFeatures{Width: 640, Widthset: 1}, nil, nil, nil, nil, nil, | |||
| 389 | + | ) | |||
| 390 | + | ||||
| 391 | + | require.True(t, blocked) | |||
| 392 | + | require.Equal(t, 1, abortCalls) | |||
| 393 | + | require.False(t, popupWV.IsDestroyed()) | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |||
| 397 | + | parentWV := &WebView{ctx: context.Background(), id: 19} | |||
| 398 | + | popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}} | |||
| 399 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 400 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |||
| 401 | + | Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true, | |||
| 402 | + | }) | |||
| 403 | + | abortCalls := 0 | |||
| 404 | + | popupWV.SetNativePopupHostAbort(func() { | |||
| 405 | + | abortCalls++ | |||
| 406 | + | popupWV.Destroy() | |||
| 407 | + | }) | |||
| 408 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 409 | + | ||||
| 410 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 411 | + | nil, nil, 93, "https://example.com/opener-required", "required", | |||
| 412 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 413 | + | ) | |||
| 414 | + | ||||
| 415 | + | require.True(t, blocked) | |||
| 416 | + | require.Equal(t, 1, abortCalls) | |||
| 417 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |||
| 418 | + | require.Nil(t, popupWV.popupOpenerBridgeParent) | |||
| 419 | + | require.True(t, popupWV.IsDestroyed()) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |||
| 423 | + | parentWV := &WebView{ctx: context.Background(), id: 20} | |||
| 424 | + | popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}} | |||
| 425 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 426 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |||
| 427 | + | Kind: dto.HostDecisionCreateNativePopup, ReasonCode: dto.HostDecisionReasonAuthNativePopup, | |||
| 428 | + | }) | |||
| 429 | + | popupWV.SetNativePopupHostAbort(func() { popupWV.Destroy() }) | |||
| 430 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 431 | + | ||||
| 432 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 433 | + | nil, nil, 94, "https://accounts.example.test/oauth", "oauth", | |||
| 434 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 435 | + | ) | |||
| 436 | + | ||||
| 437 | + | require.True(t, blocked) | |||
| 438 | + | require.False(t, popupWV.nativePopupFallbackStarted) | |||
| 439 | + | require.True(t, popupWV.IsDestroyed()) | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func TestOnBeforePopup_IsPopupFlagCanSelectNativePopup(t *testing.T) { | |||
| 443 | + | parentWV := &WebView{ctx: context.Background(), id: 22} | |||
| 444 | + | popupWV := &WebView{ctx: context.Background(), id: 30, pendingCreate: &pendingBrowserCreate{}} | |||
| 445 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 446 | + | abortCalls := 0 | |||
| 447 | + | popupWV.SetNativePopupHostAbort(func() { abortCalls++ }) | |||
| 448 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView { | |||
| 449 | + | require.True(t, req.PopupFeatures.RequestsPopupHost()) | |||
| 450 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |||
| 451 | + | return popupWV | |||
| 452 | + | }}) | |||
| 453 | + | ||||
| 454 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 455 | + | nil, nil, 96, "https://example.com/chrome-restricted", "restricted", | |||
| 456 | + | purecef.WindowOpenDispositionWodNewPopup, 1, | |||
| 457 | + | &purecef.PopupFeatures{Ispopup: 1}, nil, nil, nil, nil, nil, | |||
| 458 | + | ) | |||
| 459 | + | ||||
| 460 | + | require.True(t, blocked) | |||
| 461 | + | require.Equal(t, 1, abortCalls) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func TestOnBeforePopup_ReuseNamedPaneBlocksNativePopup(t *testing.T) { | |||
| 465 | + | parentWV := &WebView{ctx: context.Background(), id: 23} | |||
| 466 | + | popupWV := &WebView{ctx: context.Background(), id: 31, pendingCreate: &pendingBrowserCreate{}} | |||
| 467 | + | popupWV.markNativePopupCandidate(parentWV) | |||
| 468 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionReuseNamedPane}) | |||
| 469 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 470 | + | ||||
| 471 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 472 | + | nil, nil, 97, "https://example.com/named", "shared", | |||
| 473 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 474 | + | ) | |||
| 475 | + | ||||
| 476 | + | require.True(t, blocked) | |||
| 477 | + | require.True(t, popupWV.nativePopupFallbackStarted) | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | func TestOnBeforePopup_DenyCleansUp(t *testing.T) { | |||
| 481 | + | parentWV := &WebView{ctx: context.Background(), id: 24} | |||
| 482 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |||
| 483 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionDeny}) | |||
| 484 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 485 | + | ||||
| 486 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 487 | + | nil, nil, 98, "https://example.com/denied", "denied", | |||
| 488 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 489 | + | ) | |||
| 490 | + | ||||
| 491 | + | require.True(t, blocked) | |||
| 492 | + | require.True(t, popupWV.IsDestroyed()) | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | func TestOnBeforePopup_AwaitFeaturesIsImpossibleAndCleansUp(t *testing.T) { | |||
| 496 | + | parentWV := &WebView{ctx: context.Background(), id: 21} | |||
| 497 | + | popupWV := &WebView{ctx: context.Background(), id: 29} | |||
| 498 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionAwaitPopupFeatures}) | |||
| 499 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }}) | |||
| 500 | + | ||||
| 501 | + | blocked := (&handlerSet{wv: parentWV}).OnBeforePopup( | |||
| 502 | + | nil, nil, 95, "https://example.com/impossible", "impossible", | |||
| 503 | + | purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil, | |||
| 504 | + | ) | |||
| 505 | + | ||||
| 506 | + | require.True(t, blocked) | |||
| 507 | + | require.True(t, popupWV.IsDestroyed()) | |||
| 508 | + | } | |||
| 509 | + | ||||
| 265 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | 510 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | ||
| 266 | delayed := make(chan func(), 1) | 511 | delayed := make(chan func(), 1) | ||
| 267 | parentWV := &WebView{ | 512 | parentWV := &WebView{ | ||
M internal/infrastructure/webkit/webview.go +56 -7
| @@ -32,6 +32,7 @@ var _ port.WebView = (*WebView)(nil) | |||
| 32 | 32 | var _ port.DevToolsOpener = (*WebView)(nil) | |
| 33 | 33 | var _ port.Printer = (*WebView)(nil) | |
| 34 | 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 35 | + | var _ port.PopupFeatureResolver = (*WebView)(nil) | |
| 35 | 36 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 36 | 37 | ||
| 37 | 38 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| @@ -522,6 +523,44 @@ func (wv *WebView) connectCloseSignal() { | |||
| 522 | 523 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 523 | 524 | } | |
| 524 | 525 | ||
| 526 | + | func popupFeaturesFromWindowProperties( | |
| 527 | + | x, y, width, height int, | |
| 528 | + | toolbarVisible, locationbarVisible, resizable bool, | |
| 529 | + | ) dto.PopupFeatures { | |
| 530 | + | chromeRestricted := !toolbarVisible || !locationbarVisible || !resizable | |
| 531 | + | features := dto.PopupFeatures{ | |
| 532 | + | X: x, Y: y, Width: width, Height: height, | |
| 533 | + | XSet: x != 0, YSet: y != 0, WidthSet: width > 0, HeightSet: height > 0, | |
| 534 | + | ToolbarVisible: toolbarVisible, LocationbarVisible: locationbarVisible, | |
| 535 | + | ToolbarVisibilitySet: true, LocationbarVisibilitySet: true, | |
| 536 | + | Resizable: resizable, ResizableSet: true, | |
| 537 | + | IsPopup: chromeRestricted, IsPopupSet: true, | |
| 538 | + | State: dto.PopupFeaturesNone, | |
| 539 | + | } | |
| 540 | + | if width > 0 || height > 0 || chromeRestricted { | |
| 541 | + | features.State = dto.PopupFeaturesSpecified | |
| 542 | + | } | |
| 543 | + | return features | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // ResolvePopupFeatures reads WebKit's late window metadata. WebKit documents | |
| 547 | + | // these properties as reliable when ready-to-show is emitted. | |
| 548 | + | func (wv *WebView) ResolvePopupFeatures() dto.PopupFeatures { | |
| 549 | + | if wv == nil || wv.inner == nil || wv.destroyed.Load() { | |
| 550 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |
| 551 | + | } | |
| 552 | + | props := wv.inner.GetWindowProperties() | |
| 553 | + | if props == nil { | |
| 554 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |
| 555 | + | } | |
| 556 | + | geometry := &gdk.Rectangle{} | |
| 557 | + | props.GetGeometry(geometry) | |
| 558 | + | return popupFeaturesFromWindowProperties( | |
| 559 | + | geometry.X, geometry.Y, geometry.Width, geometry.Height, | |
| 560 | + | props.GetToolbarVisible(), props.GetLocationbarVisible(), props.GetResizable(), | |
| 561 | + | ) | |
| 562 | + | } | |
| 563 | + | ||
| 525 | 564 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 526 | 565 | wv.mu.Lock() | |
| 527 | 566 | wv.browsingContextDecision = decision | |
| @@ -1658,6 +1697,22 @@ func (wv *WebView) State() port.WebViewState { | |||
| 1658 | 1697 | } | |
| 1659 | 1698 | } | |
| 1660 | 1699 | ||
| 1700 | + | func mapPopupRequest(req PopupRequest) port.PopupRequest { | |
| 1701 | + | mapped := port.PopupRequest{ | |
| 1702 | + | Engine: dto.BrowserEngineWebKit, | |
| 1703 | + | TargetURI: req.TargetURI, | |
| 1704 | + | FrameName: req.FrameName, | |
| 1705 | + | IsUserGesture: req.IsUserGesture, | |
| 1706 | + | ParentViewID: req.ParentID, | |
| 1707 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1708 | + | } | |
| 1709 | + | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | |
| 1710 | + | mapped.TargetDisposition = dto.WindowDispositionNewTab | |
| 1711 | + | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |
| 1712 | + | } | |
| 1713 | + | return mapped | |
| 1714 | + | } | |
| 1715 | + | ||
| 1661 | 1716 | // SetCallbacks registers callback handlers for WebView events. | |
| 1662 | 1717 | // Pass nil to clear all callbacks. | |
| 1663 | 1718 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |
| @@ -1696,13 +1751,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1696 | 1751 | wv.OnClose = callbacks.OnClose | |
| 1697 | 1752 | if callbacks.OnCreate != nil { | |
| 1698 | 1753 | wv.OnCreate = func(req PopupRequest) *WebView { | |
| 1699 | - | portReq := port.PopupRequest{ | |
| 1700 | - | TargetURI: req.TargetURI, | |
| 1701 | - | FrameName: req.FrameName, | |
| 1702 | - | IsUserGesture: req.IsUserGesture, | |
| 1703 | - | ParentViewID: req.ParentID, | |
| 1704 | - | } | |
| 1705 | - | result := callbacks.OnCreate(portReq) | |
| 1754 | + | result := callbacks.OnCreate(mapPopupRequest(req)) | |
| 1706 | 1755 | if result == nil { | |
| 1707 | 1756 | return nil | |
| 1708 | 1757 | } | |
| @@ -32,6 +32,7 @@ var _ port.WebView = (*WebView)(nil) | |||||
| 32 | var _ port.DevToolsOpener = (*WebView)(nil) | 32 | var _ port.DevToolsOpener = (*WebView)(nil) | ||
| 33 | var _ port.Printer = (*WebView)(nil) | 33 | var _ port.Printer = (*WebView)(nil) | ||
| 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | ||
| 35 | + | var _ port.PopupFeatureResolver = (*WebView)(nil) | |||
| 35 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | 36 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | ||
| 36 | 37 | ||||
| 37 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | 38 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | ||
| @@ -522,6 +523,44 @@ func (wv *WebView) connectCloseSignal() { | |||||
| 522 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 523 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 523 | } | 524 | } | ||
| 524 | 525 | ||||
| 526 | + | func popupFeaturesFromWindowProperties( | |||
| 527 | + | x, y, width, height int, | |||
| 528 | + | toolbarVisible, locationbarVisible, resizable bool, | |||
| 529 | + | ) dto.PopupFeatures { | |||
| 530 | + | chromeRestricted := !toolbarVisible || !locationbarVisible || !resizable | |||
| 531 | + | features := dto.PopupFeatures{ | |||
| 532 | + | X: x, Y: y, Width: width, Height: height, | |||
| 533 | + | XSet: x != 0, YSet: y != 0, WidthSet: width > 0, HeightSet: height > 0, | |||
| 534 | + | ToolbarVisible: toolbarVisible, LocationbarVisible: locationbarVisible, | |||
| 535 | + | ToolbarVisibilitySet: true, LocationbarVisibilitySet: true, | |||
| 536 | + | Resizable: resizable, ResizableSet: true, | |||
| 537 | + | IsPopup: chromeRestricted, IsPopupSet: true, | |||
| 538 | + | State: dto.PopupFeaturesNone, | |||
| 539 | + | } | |||
| 540 | + | if width > 0 || height > 0 || chromeRestricted { | |||
| 541 | + | features.State = dto.PopupFeaturesSpecified | |||
| 542 | + | } | |||
| 543 | + | return features | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // ResolvePopupFeatures reads WebKit's late window metadata. WebKit documents | |||
| 547 | + | // these properties as reliable when ready-to-show is emitted. | |||
| 548 | + | func (wv *WebView) ResolvePopupFeatures() dto.PopupFeatures { | |||
| 549 | + | if wv == nil || wv.inner == nil || wv.destroyed.Load() { | |||
| 550 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |||
| 551 | + | } | |||
| 552 | + | props := wv.inner.GetWindowProperties() | |||
| 553 | + | if props == nil { | |||
| 554 | + | return dto.PopupFeatures{State: dto.PopupFeaturesUnknown} | |||
| 555 | + | } | |||
| 556 | + | geometry := &gdk.Rectangle{} | |||
| 557 | + | props.GetGeometry(geometry) | |||
| 558 | + | return popupFeaturesFromWindowProperties( | |||
| 559 | + | geometry.X, geometry.Y, geometry.Width, geometry.Height, | |||
| 560 | + | props.GetToolbarVisible(), props.GetLocationbarVisible(), props.GetResizable(), | |||
| 561 | + | ) | |||
| 562 | + | } | |||
| 563 | + | ||||
| 525 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | 564 | func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) { | ||
| 526 | wv.mu.Lock() | 565 | wv.mu.Lock() | ||
| 527 | wv.browsingContextDecision = decision | 566 | wv.browsingContextDecision = decision | ||
| @@ -1658,6 +1697,22 @@ func (wv *WebView) State() port.WebViewState { | |||||
| 1658 | } | 1697 | } | ||
| 1659 | } | 1698 | } | ||
| 1660 | 1699 | ||||
| 1700 | + | func mapPopupRequest(req PopupRequest) port.PopupRequest { | |||
| 1701 | + | mapped := port.PopupRequest{ | |||
| 1702 | + | Engine: dto.BrowserEngineWebKit, | |||
| 1703 | + | TargetURI: req.TargetURI, | |||
| 1704 | + | FrameName: req.FrameName, | |||
| 1705 | + | IsUserGesture: req.IsUserGesture, | |||
| 1706 | + | ParentViewID: req.ParentID, | |||
| 1707 | + | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |||
| 1708 | + | } | |||
| 1709 | + | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | |||
| 1710 | + | mapped.TargetDisposition = dto.WindowDispositionNewTab | |||
| 1711 | + | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |||
| 1712 | + | } | |||
| 1713 | + | return mapped | |||
| 1714 | + | } | |||
| 1715 | + | ||||
| 1661 | // SetCallbacks registers callback handlers for WebView events. | 1716 | // SetCallbacks registers callback handlers for WebView events. | ||
| 1662 | // Pass nil to clear all callbacks. | 1717 | // Pass nil to clear all callbacks. | ||
| 1663 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | 1718 | func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | ||
| @@ -1696,13 +1751,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1696 | wv.OnClose = callbacks.OnClose | 1751 | wv.OnClose = callbacks.OnClose | ||
| 1697 | if callbacks.OnCreate != nil { | 1752 | if callbacks.OnCreate != nil { | ||
| 1698 | wv.OnCreate = func(req PopupRequest) *WebView { | 1753 | wv.OnCreate = func(req PopupRequest) *WebView { | ||
| 1699 | - | portReq := port.PopupRequest{ | 1754 | + | result := callbacks.OnCreate(mapPopupRequest(req)) |
| 1700 | - | TargetURI: req.TargetURI, | |||
| 1701 | - | FrameName: req.FrameName, | |||
| 1702 | - | IsUserGesture: req.IsUserGesture, | |||
| 1703 | - | ParentViewID: req.ParentID, | |||
| 1704 | - | } | |||
| 1705 | - | result := callbacks.OnCreate(portReq) | |||
| 1706 | if result == nil { | 1755 | if result == nil { | ||
| 1707 | return nil | 1756 | return nil | ||
| 1708 | } | 1757 | } | ||
A internal/infrastructure/webkit/webview_popup_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolvePopupFeatures(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | x, y, width, height int | |
| 16 | + | toolbar, location bool | |
| 17 | + | resizable bool | |
| 18 | + | wantState dto.PopupFeatureState | |
| 19 | + | wantPopup bool | |
| 20 | + | }{ | |
| 21 | + | {name: "default properties are featureless", toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesNone}, | |
| 22 | + | {name: "geometry is popup like", width: 640, height: 480, toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 23 | + | {name: "hidden toolbar is popup like", toolbar: false, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 24 | + | {name: "hidden location bar is popup like", toolbar: true, location: false, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 25 | + | {name: "disabled resizing is popup like", toolbar: true, location: true, resizable: false, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | t.Parallel() | |
| 31 | + | got := popupFeaturesFromWindowProperties(tt.x, tt.y, tt.width, tt.height, tt.toolbar, tt.location, tt.resizable) | |
| 32 | + | assert.Equal(t, tt.wantState, got.State) | |
| 33 | + | assert.Equal(t, tt.wantPopup, got.RequestsPopupHost()) | |
| 34 | + | assert.Equal(t, tt.width > 0, got.WidthSet) | |
| 35 | + | assert.Equal(t, tt.height > 0, got.HeightSet) | |
| 36 | + | assert.True(t, got.ToolbarVisibilitySet) | |
| 37 | + | assert.True(t, got.LocationbarVisibilitySet) | |
| 38 | + | assert.True(t, got.ResizableSet) | |
| 39 | + | assert.Equal(t, tt.wantPopup && (!tt.toolbar || !tt.location || !tt.resizable), got.IsPopup) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) { | |
| 45 | + | t.Parallel() | |
| 46 | + | ||
| 47 | + | blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7}) | |
| 48 | + | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | |
| 49 | + | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | |
| 50 | + | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | |
| 51 | + | ||
| 52 | + | script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7}) | |
| 53 | + | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | |
| 54 | + | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestResolvePopupFeaturesNilWebViewIsUnknown(t *testing.T) { | |
| 58 | + | t.Parallel() | |
| 59 | + | var wv *WebView | |
| 60 | + | assert.Equal(t, dto.PopupFeaturesUnknown, wv.ResolvePopupFeatures().State) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolvePopupFeatures(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | x, y, width, height int | |||
| 16 | + | toolbar, location bool | |||
| 17 | + | resizable bool | |||
| 18 | + | wantState dto.PopupFeatureState | |||
| 19 | + | wantPopup bool | |||
| 20 | + | }{ | |||
| 21 | + | {name: "default properties are featureless", toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesNone}, | |||
| 22 | + | {name: "geometry is popup like", width: 640, height: 480, toolbar: true, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |||
| 23 | + | {name: "hidden toolbar is popup like", toolbar: false, location: true, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |||
| 24 | + | {name: "hidden location bar is popup like", toolbar: true, location: false, resizable: true, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |||
| 25 | + | {name: "disabled resizing is popup like", toolbar: true, location: true, resizable: false, wantState: dto.PopupFeaturesSpecified, wantPopup: true}, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | t.Parallel() | |||
| 31 | + | got := popupFeaturesFromWindowProperties(tt.x, tt.y, tt.width, tt.height, tt.toolbar, tt.location, tt.resizable) | |||
| 32 | + | assert.Equal(t, tt.wantState, got.State) | |||
| 33 | + | assert.Equal(t, tt.wantPopup, got.RequestsPopupHost()) | |||
| 34 | + | assert.Equal(t, tt.width > 0, got.WidthSet) | |||
| 35 | + | assert.Equal(t, tt.height > 0, got.HeightSet) | |||
| 36 | + | assert.True(t, got.ToolbarVisibilitySet) | |||
| 37 | + | assert.True(t, got.LocationbarVisibilitySet) | |||
| 38 | + | assert.True(t, got.ResizableSet) | |||
| 39 | + | assert.Equal(t, tt.wantPopup && (!tt.toolbar || !tt.location || !tt.resizable), got.IsPopup) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) { | |||
| 45 | + | t.Parallel() | |||
| 46 | + | ||||
| 47 | + | blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7}) | |||
| 48 | + | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | |||
| 49 | + | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | |||
| 50 | + | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | |||
| 51 | + | ||||
| 52 | + | script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7}) | |||
| 53 | + | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | |||
| 54 | + | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestResolvePopupFeaturesNilWebViewIsUnknown(t *testing.T) { | |||
| 58 | + | t.Parallel() | |||
| 59 | + | var wv *WebView | |||
| 60 | + | assert.Equal(t, dto.PopupFeaturesUnknown, wv.ResolvePopupFeatures().State) | |||
| 61 | + | } | |||
M internal/ui/app.go +16 -1
| @@ -615,6 +615,20 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity. | |||
| 615 | 615 | return a.lastFocusedBrowserWindow() | |
| 616 | 616 | } | |
| 617 | 617 | ||
| 618 | + | func (a *App) openPopupNativeWindow(ctx context.Context, input content.NativePopupInput) error { | |
| 619 | + | if err := a.openNativePopupWindow(ctx, input); err != nil { | |
| 620 | + | return err | |
| 621 | + | } | |
| 622 | + | // Deferred WebKit classification runs from ready-to-show. The native host | |
| 623 | + | // is attached after that signal, so it must be revealed immediately rather | |
| 624 | + | // than waiting for an already-delivered lifecycle callback. | |
| 625 | + | if input.Request.Engine == dto.BrowserEngineWebKit && | |
| 626 | + | input.Request.PopupFeatures.State == dto.PopupFeaturesSpecified { | |
| 627 | + | a.showNativePopupWindow(input.PopupWebView.ID()) | |
| 628 | + | } | |
| 629 | + | return nil | |
| 630 | + | } | |
| 631 | + | ||
| 618 | 632 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | |
| 619 | 633 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | |
| 620 | 634 | if bw == nil { | |
| @@ -3164,6 +3178,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3164 | 3178 | }) | |
| 3165 | 3179 | a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane) | |
| 3166 | 3180 | a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow) | |
| 3181 | + | a.contentCoord.SetOnStagePopup(a.stagePopup) | |
| 3167 | 3182 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | |
| 3168 | 3183 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | |
| 3169 | 3184 | a.activateBrowserWindow(bw) | |
| @@ -3176,7 +3191,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3176 | 3191 | } | |
| 3177 | 3192 | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 3178 | 3193 | }) | |
| 3179 | - | a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow) | |
| 3194 | + | a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow) | |
| 3180 | 3195 | // Wire tabbed popup behavior to create new tabs in the originating window. | |
| 3181 | 3196 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | |
| 3182 | 3197 | ||
| @@ -615,6 +615,20 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity. | |||||
| 615 | return a.lastFocusedBrowserWindow() | 615 | return a.lastFocusedBrowserWindow() | ||
| 616 | } | 616 | } | ||
| 617 | 617 | ||||
| 618 | + | func (a *App) openPopupNativeWindow(ctx context.Context, input content.NativePopupInput) error { | |||
| 619 | + | if err := a.openNativePopupWindow(ctx, input); err != nil { | |||
| 620 | + | return err | |||
| 621 | + | } | |||
| 622 | + | // Deferred WebKit classification runs from ready-to-show. The native host | |||
| 623 | + | // is attached after that signal, so it must be revealed immediately rather | |||
| 624 | + | // than waiting for an already-delivered lifecycle callback. | |||
| 625 | + | if input.Request.Engine == dto.BrowserEngineWebKit && | |||
| 626 | + | input.Request.PopupFeatures.State == dto.PopupFeaturesSpecified { | |||
| 627 | + | a.showNativePopupWindow(input.PopupWebView.ID()) | |||
| 628 | + | } | |||
| 629 | + | return nil | |||
| 630 | + | } | |||
| 631 | + | ||||
| 618 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | 632 | func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error { | ||
| 619 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | 633 | bw := a.browserWindowForAnyPane(popupInput.ParentPaneID) | ||
| 620 | if bw == nil { | 634 | if bw == nil { | ||
| @@ -3164,6 +3178,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3164 | }) | 3178 | }) | ||
| 3165 | a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane) | 3179 | a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane) | ||
| 3166 | a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow) | 3180 | a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow) | ||
| 3181 | + | a.contentCoord.SetOnStagePopup(a.stagePopup) | |||
| 3167 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | 3182 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | ||
| 3168 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | 3183 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | ||
| 3169 | a.activateBrowserWindow(bw) | 3184 | a.activateBrowserWindow(bw) | ||
| @@ -3176,7 +3191,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3176 | } | 3191 | } | ||
| 3177 | return a.wsCoord.ClosePaneByID(ctx, paneID) | 3192 | return a.wsCoord.ClosePaneByID(ctx, paneID) | ||
| 3178 | }) | 3193 | }) | ||
| 3179 | - | a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow) | 3194 | + | a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow) |
| 3180 | // Wire tabbed popup behavior to create new tabs in the originating window. | 3195 | // Wire tabbed popup behavior to create new tabs in the originating window. | ||
| 3181 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | 3196 | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | ||
| 3182 | 3197 | ||||
M internal/ui/coordinator/content/popup_manager.go +217 -2
| @@ -31,11 +31,20 @@ type popupManager struct { | |||
| 31 | 31 | policy browsingContextPolicy | |
| 32 | 32 | namedContexts *namedBrowsingContextRegistry | |
| 33 | 33 | pendingPopups map[port.WebViewID]*PendingPopup | |
| 34 | + | deferredPopups map[port.WebViewID]*deferredPendingPopup | |
| 34 | 35 | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 35 | 36 | popupRefresh map[entity.PaneID]*time.Timer | |
| 36 | 37 | mu sync.RWMutex | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 40 | + | type deferredPendingPopup struct { | |
| 41 | + | *PendingPopup | |
| 42 | + | Request port.PopupRequest | |
| 43 | + | Decision dto.HostDecision | |
| 44 | + | StagingHost PopupStagingHost | |
| 45 | + | cleanupOnce sync.Once | |
| 46 | + | } | |
| 47 | + | ||
| 39 | 48 | type popupOAuthState struct { | |
| 40 | 49 | ParentPaneID entity.PaneID | |
| 41 | 50 | ParentURIAtOpen string | |
| @@ -86,6 +95,9 @@ func (pm *popupManager) ensureInitialized() { | |||
| 86 | 95 | if pm.pendingPopups == nil { | |
| 87 | 96 | pm.pendingPopups = make(map[port.WebViewID]*PendingPopup) | |
| 88 | 97 | } | |
| 98 | + | if pm.deferredPopups == nil { | |
| 99 | + | pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup) | |
| 100 | + | } | |
| 89 | 101 | if pm.namedContexts == nil { | |
| 90 | 102 | pm.namedContexts = newNamedBrowsingContextRegistry() | |
| 91 | 103 | } | |
| @@ -356,6 +368,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, | |||
| 356 | 368 | return pending, ok | |
| 357 | 369 | } | |
| 358 | 370 | ||
| 371 | + | func (pm *popupManager) storeDeferredPopup(popupID port.WebViewID, pending *deferredPendingPopup) { | |
| 372 | + | if pm == nil || pending == nil { | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | pm.mu.Lock() | |
| 376 | + | pm.deferredPopups[popupID] = pending | |
| 377 | + | pm.mu.Unlock() | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func (pm *popupManager) takeDeferredPopup(popupID port.WebViewID) (*deferredPendingPopup, bool) { | |
| 381 | + | if pm == nil { | |
| 382 | + | return nil, false | |
| 383 | + | } | |
| 384 | + | pm.mu.Lock() | |
| 385 | + | defer pm.mu.Unlock() | |
| 386 | + | pending, ok := pm.deferredPopups[popupID] | |
| 387 | + | if ok { | |
| 388 | + | delete(pm.deferredPopups, popupID) | |
| 389 | + | } | |
| 390 | + | return pending, ok | |
| 391 | + | } | |
| 392 | + | ||
| 359 | 393 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | |
| 360 | 394 | if pm == nil { | |
| 361 | 395 | return | |
| @@ -560,8 +594,7 @@ func (pm *popupManager) handlePopupCreate( | |||
| 560 | 594 | case dto.HostDecisionCreateNativePopup: | |
| 561 | 595 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 562 | 596 | case dto.HostDecisionAwaitPopupFeatures: | |
| 563 | - | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 564 | - | return nil | |
| 597 | + | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 565 | 598 | case dto.HostDecisionCreatePane: | |
| 566 | 599 | // Continue below. | |
| 567 | 600 | break | |
| @@ -599,6 +632,188 @@ func (pm *popupManager) handlePopupCreate( | |||
| 599 | 632 | }) | |
| 600 | 633 | } | |
| 601 | 634 | ||
| 635 | + | func (pm *popupManager) awaitPopupFeatures( | |
| 636 | + | ctx context.Context, | |
| 637 | + | hooks popupCoordinatorHooks, | |
| 638 | + | parentPaneID entity.PaneID, | |
| 639 | + | parentWebViewID port.WebViewID, | |
| 640 | + | parentURIAtOpen string, | |
| 641 | + | req port.PopupRequest, | |
| 642 | + | decision dto.HostDecision, | |
| 643 | + | ) port.WebView { | |
| 644 | + | log := logging.FromContext(ctx) | |
| 645 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 646 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 647 | + | popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, false) | |
| 648 | + | if err != nil { | |
| 649 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 650 | + | return nil | |
| 651 | + | } | |
| 652 | + | resolver, resolverOK := popupWV.(port.PopupFeatureResolver) | |
| 653 | + | lifecycle, lifecycleOK := popupWV.(port.PopupLifecycleCapable) | |
| 654 | + | if !resolverOK || !lifecycleOK || pm.onStagePopup == nil { | |
| 655 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 656 | + | popupWV.Destroy() | |
| 657 | + | return nil | |
| 658 | + | } | |
| 659 | + | staging, err := pm.onStagePopup(ctx, StagePopupInput{PopupWebView: popupWV}) | |
| 660 | + | if err != nil || staging == nil { | |
| 661 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 662 | + | popupWV.Destroy() | |
| 663 | + | return nil | |
| 664 | + | } | |
| 665 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 666 | + | paneID, _ := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI) | |
| 667 | + | pending := &deferredPendingPopup{ | |
| 668 | + | PendingPopup: &PendingPopup{ | |
| 669 | + | PaneID: paneID, WebView: popupWV, ParentPaneID: parentPaneID, | |
| 670 | + | ParentWebViewID: parentWebViewID, TargetURI: req.TargetURI, FrameName: req.FrameName, | |
| 671 | + | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | |
| 672 | + | }, | |
| 673 | + | Request: req, Decision: decision, StagingHost: staging, | |
| 674 | + | } | |
| 675 | + | pm.storeDeferredPopup(popupWV.ID(), pending) | |
| 676 | + | lifecycle.SetOnReadyToShow(func() { | |
| 677 | + | pm.resolvePendingPopupFeatures(context.Background(), hooks, popupWV.ID(), parentURIAtOpen, resolver) | |
| 678 | + | }) | |
| 679 | + | lifecycle.SetOnClose(func() { | |
| 680 | + | pm.failPendingPopup(context.Background(), popupWV.ID(), dto.BrowsingContextFailureHostFailed, nil) | |
| 681 | + | }) | |
| 682 | + | return popupWV | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | func (pm *popupManager) cleanupDeferredPending(pending *deferredPendingPopup) { | |
| 686 | + | if pending == nil { | |
| 687 | + | return | |
| 688 | + | } | |
| 689 | + | pending.cleanupOnce.Do(func() { | |
| 690 | + | if pending.StagingHost != nil { | |
| 691 | + | pending.StagingHost.Destroy() | |
| 692 | + | } | |
| 693 | + | if pending.WebView != nil { | |
| 694 | + | pending.WebView.Destroy() | |
| 695 | + | } | |
| 696 | + | }) | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | func (pm *popupManager) failPendingPopup( | |
| 700 | + | ctx context.Context, | |
| 701 | + | popupID port.WebViewID, | |
| 702 | + | code dto.BrowsingContextFailureCode, | |
| 703 | + | err error, | |
| 704 | + | ) { | |
| 705 | + | pending, ok := pm.takeDeferredPopup(popupID) | |
| 706 | + | if !ok || pending == nil { | |
| 707 | + | return | |
| 708 | + | } | |
| 709 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 710 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 711 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, code, err) | |
| 712 | + | pm.cleanupDeferredPending(pending) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | func (pm *popupManager) resolvePendingPopupFeatures( | |
| 716 | + | ctx context.Context, | |
| 717 | + | hooks popupCoordinatorHooks, | |
| 718 | + | popupID port.WebViewID, | |
| 719 | + | parentURIAtOpen string, | |
| 720 | + | resolver port.PopupFeatureResolver, | |
| 721 | + | ) { | |
| 722 | + | pending, ok := pm.takeDeferredPopup(popupID) | |
| 723 | + | if !ok || pending == nil { | |
| 724 | + | return | |
| 725 | + | } | |
| 726 | + | resolved := resolver.ResolvePopupFeatures() | |
| 727 | + | if resolved.State == dto.PopupFeaturesUnknown { | |
| 728 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 729 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 730 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureFeatureResolution, nil) | |
| 731 | + | pm.cleanupDeferredPending(pending) | |
| 732 | + | return | |
| 733 | + | } | |
| 734 | + | if err := pending.StagingHost.Detach(); err != nil { | |
| 735 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |
| 736 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 737 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureStagingDetach, err) | |
| 738 | + | pm.cleanupDeferredPending(pending) | |
| 739 | + | return | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | request := pending.Request | |
| 743 | + | request.PopupFeatures = resolved | |
| 744 | + | normalized := buildPopupBrowsingContextRequest(request) | |
| 745 | + | normalized.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |
| 746 | + | namedContextExists := false | |
| 747 | + | if !request.NoJavaScriptAccess { | |
| 748 | + | _, namedContextExists = pm.lookupReusableNamedPopup(pending.ParentPaneID, request.FrameName, hooks) | |
| 749 | + | } | |
| 750 | + | decision := pm.policy.Decide(normalized, namedContextExists) | |
| 751 | + | logBrowsingContextDecision(*logging.FromContext(ctx), normalized, decision) | |
| 752 | + | pm.setBrowsingContextDecision(pending.WebView, decision) | |
| 753 | + | ||
| 754 | + | transferred := false | |
| 755 | + | switch decision.Kind { | |
| 756 | + | case dto.HostDecisionCreateBrowserWindow: | |
| 757 | + | transferred = pm.openExistingPopupInBrowserWindow(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, pending.WebView, request, decision, true) | |
| 758 | + | case dto.HostDecisionCreateNativePopup: | |
| 759 | + | transferred = pm.openExistingPopupInNativePopup(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, parentURIAtOpen, pending.WebView, request, decision) | |
| 760 | + | case dto.HostDecisionReuseNamedPane: | |
| 761 | + | _, transferred = pm.reuseNamedPopup(ctx, hooks, pending.ParentPaneID, request.FrameName, request.TargetURI) | |
| 762 | + | default: | |
| 763 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureFeatureResolution, nil) | |
| 764 | + | } | |
| 765 | + | if !transferred { | |
| 766 | + | pm.cleanupDeferredPending(pending) | |
| 767 | + | } | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | func (pm *popupManager) openExistingPopupInNativePopup( | |
| 771 | + | ctx context.Context, | |
| 772 | + | hooks popupCoordinatorHooks, | |
| 773 | + | parentPaneID entity.PaneID, | |
| 774 | + | parentWebViewID port.WebViewID, | |
| 775 | + | parentURIAtOpen string, | |
| 776 | + | popupWV port.WebView, | |
| 777 | + | req port.PopupRequest, | |
| 778 | + | decision dto.HostDecision, | |
| 779 | + | ) bool { | |
| 780 | + | log := logging.FromContext(ctx) | |
| 781 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 782 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 783 | + | if popupWV == nil || pm.onOpenNativePopup == nil { | |
| 784 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 785 | + | return false | |
| 786 | + | } | |
| 787 | + | cfg := pm.currentPopupConfig() | |
| 788 | + | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && | |
| 789 | + | (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV)) | |
| 790 | + | var abortOnce sync.Once | |
| 791 | + | abortResult := false | |
| 792 | + | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |
| 793 | + | abortOnce.Do(func() { | |
| 794 | + | if allowFallback { | |
| 795 | + | abortResult = pm.openExistingPopupInBrowserWindow( | |
| 796 | + | abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true, | |
| 797 | + | ) | |
| 798 | + | } | |
| 799 | + | }) | |
| 800 | + | return abortResult | |
| 801 | + | } | |
| 802 | + | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |
| 803 | + | ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen, | |
| 804 | + | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, | |
| 805 | + | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |
| 806 | + | AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort, | |
| 807 | + | }); err != nil { | |
| 808 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 809 | + | return false | |
| 810 | + | } | |
| 811 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 812 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 813 | + | } | |
| 814 | + | return true | |
| 815 | + | } | |
| 816 | + | ||
| 602 | 817 | func (pm *popupManager) finishPopupCreate( | |
| 603 | 818 | ctx context.Context, | |
| 604 | 819 | hooks popupCoordinatorHooks, | |
| @@ -31,11 +31,20 @@ type popupManager struct { | |||||
| 31 | policy browsingContextPolicy | 31 | policy browsingContextPolicy | ||
| 32 | namedContexts *namedBrowsingContextRegistry | 32 | namedContexts *namedBrowsingContextRegistry | ||
| 33 | pendingPopups map[port.WebViewID]*PendingPopup | 33 | pendingPopups map[port.WebViewID]*PendingPopup | ||
| 34 | + | deferredPopups map[port.WebViewID]*deferredPendingPopup | |||
| 34 | popupOAuth map[port.WebViewID]*popupOAuthState | 35 | popupOAuth map[port.WebViewID]*popupOAuthState | ||
| 35 | popupRefresh map[entity.PaneID]*time.Timer | 36 | popupRefresh map[entity.PaneID]*time.Timer | ||
| 36 | mu sync.RWMutex | 37 | mu sync.RWMutex | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 40 | + | type deferredPendingPopup struct { | |||
| 41 | + | *PendingPopup | |||
| 42 | + | Request port.PopupRequest | |||
| 43 | + | Decision dto.HostDecision | |||
| 44 | + | StagingHost PopupStagingHost | |||
| 45 | + | cleanupOnce sync.Once | |||
| 46 | + | } | |||
| 47 | + | ||||
| 39 | type popupOAuthState struct { | 48 | type popupOAuthState struct { | ||
| 40 | ParentPaneID entity.PaneID | 49 | ParentPaneID entity.PaneID | ||
| 41 | ParentURIAtOpen string | 50 | ParentURIAtOpen string | ||
| @@ -86,6 +95,9 @@ func (pm *popupManager) ensureInitialized() { | |||||
| 86 | if pm.pendingPopups == nil { | 95 | if pm.pendingPopups == nil { | ||
| 87 | pm.pendingPopups = make(map[port.WebViewID]*PendingPopup) | 96 | pm.pendingPopups = make(map[port.WebViewID]*PendingPopup) | ||
| 88 | } | 97 | } | ||
| 98 | + | if pm.deferredPopups == nil { | |||
| 99 | + | pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup) | |||
| 100 | + | } | |||
| 89 | if pm.namedContexts == nil { | 101 | if pm.namedContexts == nil { | ||
| 90 | pm.namedContexts = newNamedBrowsingContextRegistry() | 102 | pm.namedContexts = newNamedBrowsingContextRegistry() | ||
| 91 | } | 103 | } | ||
| @@ -356,6 +368,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, | |||||
| 356 | return pending, ok | 368 | return pending, ok | ||
| 357 | } | 369 | } | ||
| 358 | 370 | ||||
| 371 | + | func (pm *popupManager) storeDeferredPopup(popupID port.WebViewID, pending *deferredPendingPopup) { | |||
| 372 | + | if pm == nil || pending == nil { | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | pm.mu.Lock() | |||
| 376 | + | pm.deferredPopups[popupID] = pending | |||
| 377 | + | pm.mu.Unlock() | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func (pm *popupManager) takeDeferredPopup(popupID port.WebViewID) (*deferredPendingPopup, bool) { | |||
| 381 | + | if pm == nil { | |||
| 382 | + | return nil, false | |||
| 383 | + | } | |||
| 384 | + | pm.mu.Lock() | |||
| 385 | + | defer pm.mu.Unlock() | |||
| 386 | + | pending, ok := pm.deferredPopups[popupID] | |||
| 387 | + | if ok { | |||
| 388 | + | delete(pm.deferredPopups, popupID) | |||
| 389 | + | } | |||
| 390 | + | return pending, ok | |||
| 391 | + | } | |||
| 392 | + | ||||
| 359 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | 393 | func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) { | ||
| 360 | if pm == nil { | 394 | if pm == nil { | ||
| 361 | return | 395 | return | ||
| @@ -560,8 +594,7 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 560 | case dto.HostDecisionCreateNativePopup: | 594 | case dto.HostDecisionCreateNativePopup: | ||
| 561 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | 595 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | ||
| 562 | case dto.HostDecisionAwaitPopupFeatures: | 596 | case dto.HostDecisionAwaitPopupFeatures: | ||
| 563 | - | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil) | 597 | + | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) |
| 564 | - | return nil | |||
| 565 | case dto.HostDecisionCreatePane: | 598 | case dto.HostDecisionCreatePane: | ||
| 566 | // Continue below. | 599 | // Continue below. | ||
| 567 | break | 600 | break | ||
| @@ -599,6 +632,188 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 599 | }) | 632 | }) | ||
| 600 | } | 633 | } | ||
| 601 | 634 | ||||
| 635 | + | func (pm *popupManager) awaitPopupFeatures( | |||
| 636 | + | ctx context.Context, | |||
| 637 | + | hooks popupCoordinatorHooks, | |||
| 638 | + | parentPaneID entity.PaneID, | |||
| 639 | + | parentWebViewID port.WebViewID, | |||
| 640 | + | parentURIAtOpen string, | |||
| 641 | + | req port.PopupRequest, | |||
| 642 | + | decision dto.HostDecision, | |||
| 643 | + | ) port.WebView { | |||
| 644 | + | log := logging.FromContext(ctx) | |||
| 645 | + | normalized := buildPopupBrowsingContextRequest(req) | |||
| 646 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |||
| 647 | + | popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, false) | |||
| 648 | + | if err != nil { | |||
| 649 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 650 | + | return nil | |||
| 651 | + | } | |||
| 652 | + | resolver, resolverOK := popupWV.(port.PopupFeatureResolver) | |||
| 653 | + | lifecycle, lifecycleOK := popupWV.(port.PopupLifecycleCapable) | |||
| 654 | + | if !resolverOK || !lifecycleOK || pm.onStagePopup == nil { | |||
| 655 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |||
| 656 | + | popupWV.Destroy() | |||
| 657 | + | return nil | |||
| 658 | + | } | |||
| 659 | + | staging, err := pm.onStagePopup(ctx, StagePopupInput{PopupWebView: popupWV}) | |||
| 660 | + | if err != nil || staging == nil { | |||
| 661 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 662 | + | popupWV.Destroy() | |||
| 663 | + | return nil | |||
| 664 | + | } | |||
| 665 | + | pm.setBrowsingContextDecision(popupWV, decision) | |||
| 666 | + | paneID, _ := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI) | |||
| 667 | + | pending := &deferredPendingPopup{ | |||
| 668 | + | PendingPopup: &PendingPopup{ | |||
| 669 | + | PaneID: paneID, WebView: popupWV, ParentPaneID: parentPaneID, | |||
| 670 | + | ParentWebViewID: parentWebViewID, TargetURI: req.TargetURI, FrameName: req.FrameName, | |||
| 671 | + | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | |||
| 672 | + | }, | |||
| 673 | + | Request: req, Decision: decision, StagingHost: staging, | |||
| 674 | + | } | |||
| 675 | + | pm.storeDeferredPopup(popupWV.ID(), pending) | |||
| 676 | + | lifecycle.SetOnReadyToShow(func() { | |||
| 677 | + | pm.resolvePendingPopupFeatures(context.Background(), hooks, popupWV.ID(), parentURIAtOpen, resolver) | |||
| 678 | + | }) | |||
| 679 | + | lifecycle.SetOnClose(func() { | |||
| 680 | + | pm.failPendingPopup(context.Background(), popupWV.ID(), dto.BrowsingContextFailureHostFailed, nil) | |||
| 681 | + | }) | |||
| 682 | + | return popupWV | |||
| 683 | + | } | |||
| 684 | + | ||||
| 685 | + | func (pm *popupManager) cleanupDeferredPending(pending *deferredPendingPopup) { | |||
| 686 | + | if pending == nil { | |||
| 687 | + | return | |||
| 688 | + | } | |||
| 689 | + | pending.cleanupOnce.Do(func() { | |||
| 690 | + | if pending.StagingHost != nil { | |||
| 691 | + | pending.StagingHost.Destroy() | |||
| 692 | + | } | |||
| 693 | + | if pending.WebView != nil { | |||
| 694 | + | pending.WebView.Destroy() | |||
| 695 | + | } | |||
| 696 | + | }) | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | func (pm *popupManager) failPendingPopup( | |||
| 700 | + | ctx context.Context, | |||
| 701 | + | popupID port.WebViewID, | |||
| 702 | + | code dto.BrowsingContextFailureCode, | |||
| 703 | + | err error, | |||
| 704 | + | ) { | |||
| 705 | + | pending, ok := pm.takeDeferredPopup(popupID) | |||
| 706 | + | if !ok || pending == nil { | |||
| 707 | + | return | |||
| 708 | + | } | |||
| 709 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |||
| 710 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |||
| 711 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, code, err) | |||
| 712 | + | pm.cleanupDeferredPending(pending) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | func (pm *popupManager) resolvePendingPopupFeatures( | |||
| 716 | + | ctx context.Context, | |||
| 717 | + | hooks popupCoordinatorHooks, | |||
| 718 | + | popupID port.WebViewID, | |||
| 719 | + | parentURIAtOpen string, | |||
| 720 | + | resolver port.PopupFeatureResolver, | |||
| 721 | + | ) { | |||
| 722 | + | pending, ok := pm.takeDeferredPopup(popupID) | |||
| 723 | + | if !ok || pending == nil { | |||
| 724 | + | return | |||
| 725 | + | } | |||
| 726 | + | resolved := resolver.ResolvePopupFeatures() | |||
| 727 | + | if resolved.State == dto.PopupFeaturesUnknown { | |||
| 728 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |||
| 729 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |||
| 730 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureFeatureResolution, nil) | |||
| 731 | + | pm.cleanupDeferredPending(pending) | |||
| 732 | + | return | |||
| 733 | + | } | |||
| 734 | + | if err := pending.StagingHost.Detach(); err != nil { | |||
| 735 | + | req := buildPopupBrowsingContextRequest(pending.Request) | |||
| 736 | + | req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |||
| 737 | + | logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureStagingDetach, err) | |||
| 738 | + | pm.cleanupDeferredPending(pending) | |||
| 739 | + | return | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | request := pending.Request | |||
| 743 | + | request.PopupFeatures = resolved | |||
| 744 | + | normalized := buildPopupBrowsingContextRequest(request) | |||
| 745 | + | normalized.SourceHost = pm.resolveSourceHost(pending.ParentPaneID) | |||
| 746 | + | namedContextExists := false | |||
| 747 | + | if !request.NoJavaScriptAccess { | |||
| 748 | + | _, namedContextExists = pm.lookupReusableNamedPopup(pending.ParentPaneID, request.FrameName, hooks) | |||
| 749 | + | } | |||
| 750 | + | decision := pm.policy.Decide(normalized, namedContextExists) | |||
| 751 | + | logBrowsingContextDecision(*logging.FromContext(ctx), normalized, decision) | |||
| 752 | + | pm.setBrowsingContextDecision(pending.WebView, decision) | |||
| 753 | + | ||||
| 754 | + | transferred := false | |||
| 755 | + | switch decision.Kind { | |||
| 756 | + | case dto.HostDecisionCreateBrowserWindow: | |||
| 757 | + | transferred = pm.openExistingPopupInBrowserWindow(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, pending.WebView, request, decision, true) | |||
| 758 | + | case dto.HostDecisionCreateNativePopup: | |||
| 759 | + | transferred = pm.openExistingPopupInNativePopup(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, parentURIAtOpen, pending.WebView, request, decision) | |||
| 760 | + | case dto.HostDecisionReuseNamedPane: | |||
| 761 | + | _, transferred = pm.reuseNamedPopup(ctx, hooks, pending.ParentPaneID, request.FrameName, request.TargetURI) | |||
| 762 | + | default: | |||
| 763 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureFeatureResolution, nil) | |||
| 764 | + | } | |||
| 765 | + | if !transferred { | |||
| 766 | + | pm.cleanupDeferredPending(pending) | |||
| 767 | + | } | |||
| 768 | + | } | |||
| 769 | + | ||||
| 770 | + | func (pm *popupManager) openExistingPopupInNativePopup( | |||
| 771 | + | ctx context.Context, | |||
| 772 | + | hooks popupCoordinatorHooks, | |||
| 773 | + | parentPaneID entity.PaneID, | |||
| 774 | + | parentWebViewID port.WebViewID, | |||
| 775 | + | parentURIAtOpen string, | |||
| 776 | + | popupWV port.WebView, | |||
| 777 | + | req port.PopupRequest, | |||
| 778 | + | decision dto.HostDecision, | |||
| 779 | + | ) bool { | |||
| 780 | + | log := logging.FromContext(ctx) | |||
| 781 | + | normalized := buildPopupBrowsingContextRequest(req) | |||
| 782 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |||
| 783 | + | if popupWV == nil || pm.onOpenNativePopup == nil { | |||
| 784 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |||
| 785 | + | return false | |||
| 786 | + | } | |||
| 787 | + | cfg := pm.currentPopupConfig() | |||
| 788 | + | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && | |||
| 789 | + | (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV)) | |||
| 790 | + | var abortOnce sync.Once | |||
| 791 | + | abortResult := false | |||
| 792 | + | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |||
| 793 | + | abortOnce.Do(func() { | |||
| 794 | + | if allowFallback { | |||
| 795 | + | abortResult = pm.openExistingPopupInBrowserWindow( | |||
| 796 | + | abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true, | |||
| 797 | + | ) | |||
| 798 | + | } | |||
| 799 | + | }) | |||
| 800 | + | return abortResult | |||
| 801 | + | } | |||
| 802 | + | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |||
| 803 | + | ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen, | |||
| 804 | + | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, | |||
| 805 | + | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |||
| 806 | + | AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort, | |||
| 807 | + | }); err != nil { | |||
| 808 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 809 | + | return false | |||
| 810 | + | } | |||
| 811 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |||
| 812 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |||
| 813 | + | } | |||
| 814 | + | return true | |||
| 815 | + | } | |||
| 816 | + | ||||
| 602 | func (pm *popupManager) finishPopupCreate( | 817 | func (pm *popupManager) finishPopupCreate( | ||
| 603 | ctx context.Context, | 818 | ctx context.Context, | ||
| 604 | hooks popupCoordinatorHooks, | 819 | hooks popupCoordinatorHooks, | ||
M internal/ui/coordinator/content/popup_test.go +171 -0
| @@ -1232,3 +1232,174 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||
| 1232 | 1232 | defer c.popups.mu.RUnlock() | |
| 1233 | 1233 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | |
| 1234 | 1234 | } | |
| 1235 | + | ||
| 1236 | + | func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | |
| 1237 | + | ctx := context.Background() | |
| 1238 | + | parent := mocks.NewMockWebView(t) | |
| 1239 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1240 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 1241 | + | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 1242 | + | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 1243 | + | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 1244 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 1245 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1246 | + | staging := &popupStagingHostStub{} | |
| 1247 | + | c := &Coordinator{popups: newPopupManager()} | |
| 1248 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |
| 1249 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 1250 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 1251 | + | staging.attached = true | |
| 1252 | + | return staging, nil | |
| 1253 | + | }) | |
| 1254 | + | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 1255 | + | assert.True(t, staging.detached, "staging must detach before final host") | |
| 1256 | + | assert.True(t, input.Ready) | |
| 1257 | + | assert.Same(t, popup, input.PopupWebView) | |
| 1258 | + | return BrowserWindowResult{WindowID: "browser"}, nil | |
| 1259 | + | }) | |
| 1260 | + | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | |
| 1261 | + | t.Fatal("featureless popup must not use native host") | |
| 1262 | + | return nil | |
| 1263 | + | }) | |
| 1264 | + | ||
| 1265 | + | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}) | |
| 1266 | + | require.Same(t, popup, got) | |
| 1267 | + | assert.True(t, staging.attached) | |
| 1268 | + | assert.False(t, staging.detached) | |
| 1269 | + | popup.ready() | |
| 1270 | + | assert.True(t, staging.detached) | |
| 1271 | + | assert.Zero(t, staging.destroyCalls) | |
| 1272 | + | } | |
| 1273 | + | ||
| 1274 | + | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |
| 1275 | + | ctx := context.Background() | |
| 1276 | + | parent := mocks.NewMockWebView(t) | |
| 1277 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1278 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}} | |
| 1279 | + | popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 1280 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 1281 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1282 | + | staging := &popupStagingHostStub{} | |
| 1283 | + | c := &Coordinator{popups: newPopupManager()} | |
| 1284 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |
| 1285 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 1286 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 1287 | + | staging.attached = true | |
| 1288 | + | return staging, nil | |
| 1289 | + | }) | |
| 1290 | + | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |
| 1291 | + | assert.True(t, staging.detached) | |
| 1292 | + | assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State) | |
| 1293 | + | assert.Same(t, popup, input.PopupWebView) | |
| 1294 | + | return nil | |
| 1295 | + | }) | |
| 1296 | + | ||
| 1297 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 1298 | + | popup.ready() | |
| 1299 | + | } | |
| 1300 | + | ||
| 1301 | + | func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |
| 1302 | + | parent := mocks.NewMockWebView(t) | |
| 1303 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1304 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 1305 | + | popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe() | |
| 1306 | + | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 1307 | + | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 1308 | + | popup.EXPECT().Destroy().Once() | |
| 1309 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 1310 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1311 | + | staging := &popupStagingHostStub{} | |
| 1312 | + | c := &Coordinator{popups: newPopupManager()} | |
| 1313 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 1314 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 1315 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 1316 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 1317 | + | return BrowserWindowResult{}, errors.New("host failed") | |
| 1318 | + | }) | |
| 1319 | + | ||
| 1320 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 1321 | + | popup.ready() | |
| 1322 | + | popup.ready() | |
| 1323 | + | assert.True(t, staging.detached) | |
| 1324 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 1325 | + | } | |
| 1326 | + | ||
| 1327 | + | func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) { | |
| 1328 | + | parent := mocks.NewMockWebView(t) | |
| 1329 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1330 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 1331 | + | popup.EXPECT().Destroy().Once() | |
| 1332 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 1333 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1334 | + | c := &Coordinator{popups: newPopupManager()} | |
| 1335 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 1336 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 1337 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |
| 1338 | + | return nil, errors.New("stage failed") | |
| 1339 | + | }) | |
| 1340 | + | ||
| 1341 | + | assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 1342 | + | } | |
| 1343 | + | ||
| 1344 | + | func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) { | |
| 1345 | + | for _, tc := range []struct { | |
| 1346 | + | name string | |
| 1347 | + | close bool | |
| 1348 | + | }{{name: "unknown at ready"}, {name: "close before ready", close: true}} { | |
| 1349 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1350 | + | parent := mocks.NewMockWebView(t) | |
| 1351 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1352 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}} | |
| 1353 | + | popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe() | |
| 1354 | + | popup.EXPECT().Destroy().Once() | |
| 1355 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 1356 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1357 | + | staging := &popupStagingHostStub{} | |
| 1358 | + | c := &Coordinator{popups: newPopupManager()} | |
| 1359 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |
| 1360 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 1361 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |
| 1362 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 1363 | + | if tc.close { | |
| 1364 | + | popup.close() | |
| 1365 | + | popup.close() | |
| 1366 | + | } else { | |
| 1367 | + | popup.ready() | |
| 1368 | + | popup.ready() | |
| 1369 | + | } | |
| 1370 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 1371 | + | }) | |
| 1372 | + | } | |
| 1373 | + | } | |
| 1374 | + | ||
| 1375 | + | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | |
| 1376 | + | type deferredPopupWebViewStub struct { | |
| 1377 | + | *mocks.MockWebView | |
| 1378 | + | features dto.PopupFeatures | |
| 1379 | + | onReady func() | |
| 1380 | + | onClose func() | |
| 1381 | + | } | |
| 1382 | + | ||
| 1383 | + | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | |
| 1384 | + | func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {} | |
| 1385 | + | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | |
| 1386 | + | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | |
| 1387 | + | func (s *deferredPopupWebViewStub) Show() {} | |
| 1388 | + | func (s *deferredPopupWebViewStub) ready() { | |
| 1389 | + | if s.onReady != nil { | |
| 1390 | + | s.onReady() | |
| 1391 | + | } | |
| 1392 | + | } | |
| 1393 | + | func (s *deferredPopupWebViewStub) close() { | |
| 1394 | + | if s.onClose != nil { | |
| 1395 | + | s.onClose() | |
| 1396 | + | } | |
| 1397 | + | } | |
| 1398 | + | ||
| 1399 | + | type popupStagingHostStub struct { | |
| 1400 | + | attached, detached bool | |
| 1401 | + | destroyCalls int | |
| 1402 | + | } | |
| 1403 | + | ||
| 1404 | + | func (s *popupStagingHostStub) Detach() error { s.detached = true; return nil } | |
| 1405 | + | func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ } | |
| @@ -1232,3 +1232,174 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||||
| 1232 | defer c.popups.mu.RUnlock() | 1232 | defer c.popups.mu.RUnlock() | ||
| 1233 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | 1233 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | ||
| 1234 | } | 1234 | } | ||
| 1235 | + | ||||
| 1236 | + | func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | |||
| 1237 | + | ctx := context.Background() | |||
| 1238 | + | parent := mocks.NewMockWebView(t) | |||
| 1239 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 1240 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |||
| 1241 | + | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |||
| 1242 | + | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |||
| 1243 | + | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |||
| 1244 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 1245 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |||
| 1246 | + | staging := &popupStagingHostStub{} | |||
| 1247 | + | c := &Coordinator{popups: newPopupManager()} | |||
| 1248 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |||
| 1249 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 1250 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |||
| 1251 | + | staging.attached = true | |||
| 1252 | + | return staging, nil | |||
| 1253 | + | }) | |||
| 1254 | + | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |||
| 1255 | + | assert.True(t, staging.detached, "staging must detach before final host") | |||
| 1256 | + | assert.True(t, input.Ready) | |||
| 1257 | + | assert.Same(t, popup, input.PopupWebView) | |||
| 1258 | + | return BrowserWindowResult{WindowID: "browser"}, nil | |||
| 1259 | + | }) | |||
| 1260 | + | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | |||
| 1261 | + | t.Fatal("featureless popup must not use native host") | |||
| 1262 | + | return nil | |||
| 1263 | + | }) | |||
| 1264 | + | ||||
| 1265 | + | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}) | |||
| 1266 | + | require.Same(t, popup, got) | |||
| 1267 | + | assert.True(t, staging.attached) | |||
| 1268 | + | assert.False(t, staging.detached) | |||
| 1269 | + | popup.ready() | |||
| 1270 | + | assert.True(t, staging.detached) | |||
| 1271 | + | assert.Zero(t, staging.destroyCalls) | |||
| 1272 | + | } | |||
| 1273 | + | ||||
| 1274 | + | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |||
| 1275 | + | ctx := context.Background() | |||
| 1276 | + | parent := mocks.NewMockWebView(t) | |||
| 1277 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 1278 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}} | |||
| 1279 | + | popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |||
| 1280 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 1281 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |||
| 1282 | + | staging := &popupStagingHostStub{} | |||
| 1283 | + | c := &Coordinator{popups: newPopupManager()} | |||
| 1284 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" }) | |||
| 1285 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 1286 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |||
| 1287 | + | staging.attached = true | |||
| 1288 | + | return staging, nil | |||
| 1289 | + | }) | |||
| 1290 | + | c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error { | |||
| 1291 | + | assert.True(t, staging.detached) | |||
| 1292 | + | assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State) | |||
| 1293 | + | assert.Same(t, popup, input.PopupWebView) | |||
| 1294 | + | return nil | |||
| 1295 | + | }) | |||
| 1296 | + | ||||
| 1297 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |||
| 1298 | + | popup.ready() | |||
| 1299 | + | } | |||
| 1300 | + | ||||
| 1301 | + | func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |||
| 1302 | + | parent := mocks.NewMockWebView(t) | |||
| 1303 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 1304 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |||
| 1305 | + | popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe() | |||
| 1306 | + | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |||
| 1307 | + | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |||
| 1308 | + | popup.EXPECT().Destroy().Once() | |||
| 1309 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 1310 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |||
| 1311 | + | staging := &popupStagingHostStub{} | |||
| 1312 | + | c := &Coordinator{popups: newPopupManager()} | |||
| 1313 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |||
| 1314 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 1315 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |||
| 1316 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |||
| 1317 | + | return BrowserWindowResult{}, errors.New("host failed") | |||
| 1318 | + | }) | |||
| 1319 | + | ||||
| 1320 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |||
| 1321 | + | popup.ready() | |||
| 1322 | + | popup.ready() | |||
| 1323 | + | assert.True(t, staging.detached) | |||
| 1324 | + | assert.Equal(t, 1, staging.destroyCalls) | |||
| 1325 | + | } | |||
| 1326 | + | ||||
| 1327 | + | func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) { | |||
| 1328 | + | parent := mocks.NewMockWebView(t) | |||
| 1329 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 1330 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |||
| 1331 | + | popup.EXPECT().Destroy().Once() | |||
| 1332 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 1333 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |||
| 1334 | + | c := &Coordinator{popups: newPopupManager()} | |||
| 1335 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |||
| 1336 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 1337 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { | |||
| 1338 | + | return nil, errors.New("stage failed") | |||
| 1339 | + | }) | |||
| 1340 | + | ||||
| 1341 | + | assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |||
| 1342 | + | } | |||
| 1343 | + | ||||
| 1344 | + | func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) { | |||
| 1345 | + | for _, tc := range []struct { | |||
| 1346 | + | name string | |||
| 1347 | + | close bool | |||
| 1348 | + | }{{name: "unknown at ready"}, {name: "close before ready", close: true}} { | |||
| 1349 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1350 | + | parent := mocks.NewMockWebView(t) | |||
| 1351 | + | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 1352 | + | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}} | |||
| 1353 | + | popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe() | |||
| 1354 | + | popup.EXPECT().Destroy().Once() | |||
| 1355 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 1356 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |||
| 1357 | + | staging := &popupStagingHostStub{} | |||
| 1358 | + | c := &Coordinator{popups: newPopupManager()} | |||
| 1359 | + | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil) | |||
| 1360 | + | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 1361 | + | c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil }) | |||
| 1362 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |||
| 1363 | + | if tc.close { | |||
| 1364 | + | popup.close() | |||
| 1365 | + | popup.close() | |||
| 1366 | + | } else { | |||
| 1367 | + | popup.ready() | |||
| 1368 | + | popup.ready() | |||
| 1369 | + | } | |||
| 1370 | + | assert.Equal(t, 1, staging.destroyCalls) | |||
| 1371 | + | }) | |||
| 1372 | + | } | |||
| 1373 | + | } | |||
| 1374 | + | ||||
| 1375 | + | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | |||
| 1376 | + | type deferredPopupWebViewStub struct { | |||
| 1377 | + | *mocks.MockWebView | |||
| 1378 | + | features dto.PopupFeatures | |||
| 1379 | + | onReady func() | |||
| 1380 | + | onClose func() | |||
| 1381 | + | } | |||
| 1382 | + | ||||
| 1383 | + | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | |||
| 1384 | + | func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {} | |||
| 1385 | + | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | |||
| 1386 | + | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | |||
| 1387 | + | func (s *deferredPopupWebViewStub) Show() {} | |||
| 1388 | + | func (s *deferredPopupWebViewStub) ready() { | |||
| 1389 | + | if s.onReady != nil { | |||
| 1390 | + | s.onReady() | |||
| 1391 | + | } | |||
| 1392 | + | } | |||
| 1393 | + | func (s *deferredPopupWebViewStub) close() { | |||
| 1394 | + | if s.onClose != nil { | |||
| 1395 | + | s.onClose() | |||
| 1396 | + | } | |||
| 1397 | + | } | |||
| 1398 | + | ||||
| 1399 | + | type popupStagingHostStub struct { | |||
| 1400 | + | attached, detached bool | |||
| 1401 | + | destroyCalls int | |||
| 1402 | + | } | |||
| 1403 | + | ||||
| 1404 | + | func (s *popupStagingHostStub) Detach() error { s.detached = true; return nil } | |||
| 1405 | + | func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ } | |||
A internal/ui/popup_staging_host.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 9 | + | "github.com/bnema/dumber/internal/ui/window" | |
| 10 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type popupStagingShell interface { | |
| 14 | + | SetContent(*gtk.Widget) | |
| 15 | + | DetachContent() *gtk.Widget | |
| 16 | + | Destroy() | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type popupStagingHost struct { | |
| 20 | + | shell popupStagingShell | |
| 21 | + | attached *gtk.Widget | |
| 22 | + | once sync.Once | |
| 23 | + | cleanupErr error | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var newPopupStagingWindow = func(ctx context.Context, app *gtk.Application) (popupStagingShell, error) { | |
| 27 | + | return window.NewPopup(ctx, app) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (h *popupStagingHost) cleanup() { | |
| 31 | + | if h == nil { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | h.once.Do(func() { | |
| 35 | + | if h.shell != nil { | |
| 36 | + | detached := h.shell.DetachContent() | |
| 37 | + | if h.attached != nil && detached == nil { | |
| 38 | + | h.cleanupErr = fmt.Errorf("popup staging content was not attached") | |
| 39 | + | } | |
| 40 | + | h.shell.Destroy() | |
| 41 | + | } | |
| 42 | + | h.attached = nil | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (h *popupStagingHost) Detach() error { | |
| 47 | + | h.cleanup() | |
| 48 | + | return h.cleanupErr | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (h *popupStagingHost) Destroy() { h.cleanup() } | |
| 52 | + | ||
| 53 | + | // stagePopup temporarily parents a WebKit popup widget in a hidden GTK shell. | |
| 54 | + | // The shell is deliberately never shown or presented. | |
| 55 | + | func (a *App) stagePopup(ctx context.Context, input content.StagePopupInput) (content.PopupStagingHost, error) { | |
| 56 | + | if a == nil || a.gtkApp == nil { | |
| 57 | + | return nil, fmt.Errorf("gtk application not available for popup staging") | |
| 58 | + | } | |
| 59 | + | if input.PopupWebView == nil { | |
| 60 | + | return nil, fmt.Errorf("popup staging webview is nil") | |
| 61 | + | } | |
| 62 | + | if a.contentCoord == nil { | |
| 63 | + | return nil, fmt.Errorf("content coordinator not available for popup staging") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | shell, err := newPopupStagingWindow(ctx, a.gtkApp) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | |
| 71 | + | if widget == nil || widget.GtkWidget() == nil { | |
| 72 | + | shell.Destroy() | |
| 73 | + | return nil, fmt.Errorf("failed to wrap popup staging webview widget") | |
| 74 | + | } | |
| 75 | + | gtkWidget := widget.GtkWidget() | |
| 76 | + | widget.SetHexpand(true) | |
| 77 | + | widget.SetVexpand(true) | |
| 78 | + | shell.SetContent(gtkWidget) | |
| 79 | + | return &popupStagingHost{shell: shell, attached: gtkWidget}, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | var _ content.PopupStagingHost = (*popupStagingHost)(nil) | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |||
| 9 | + | "github.com/bnema/dumber/internal/ui/window" | |||
| 10 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type popupStagingShell interface { | |||
| 14 | + | SetContent(*gtk.Widget) | |||
| 15 | + | DetachContent() *gtk.Widget | |||
| 16 | + | Destroy() | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type popupStagingHost struct { | |||
| 20 | + | shell popupStagingShell | |||
| 21 | + | attached *gtk.Widget | |||
| 22 | + | once sync.Once | |||
| 23 | + | cleanupErr error | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | var newPopupStagingWindow = func(ctx context.Context, app *gtk.Application) (popupStagingShell, error) { | |||
| 27 | + | return window.NewPopup(ctx, app) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (h *popupStagingHost) cleanup() { | |||
| 31 | + | if h == nil { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | h.once.Do(func() { | |||
| 35 | + | if h.shell != nil { | |||
| 36 | + | detached := h.shell.DetachContent() | |||
| 37 | + | if h.attached != nil && detached == nil { | |||
| 38 | + | h.cleanupErr = fmt.Errorf("popup staging content was not attached") | |||
| 39 | + | } | |||
| 40 | + | h.shell.Destroy() | |||
| 41 | + | } | |||
| 42 | + | h.attached = nil | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (h *popupStagingHost) Detach() error { | |||
| 47 | + | h.cleanup() | |||
| 48 | + | return h.cleanupErr | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (h *popupStagingHost) Destroy() { h.cleanup() } | |||
| 52 | + | ||||
| 53 | + | // stagePopup temporarily parents a WebKit popup widget in a hidden GTK shell. | |||
| 54 | + | // The shell is deliberately never shown or presented. | |||
| 55 | + | func (a *App) stagePopup(ctx context.Context, input content.StagePopupInput) (content.PopupStagingHost, error) { | |||
| 56 | + | if a == nil || a.gtkApp == nil { | |||
| 57 | + | return nil, fmt.Errorf("gtk application not available for popup staging") | |||
| 58 | + | } | |||
| 59 | + | if input.PopupWebView == nil { | |||
| 60 | + | return nil, fmt.Errorf("popup staging webview is nil") | |||
| 61 | + | } | |||
| 62 | + | if a.contentCoord == nil { | |||
| 63 | + | return nil, fmt.Errorf("content coordinator not available for popup staging") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | shell, err := newPopupStagingWindow(ctx, a.gtkApp) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return nil, err | |||
| 69 | + | } | |||
| 70 | + | widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView) | |||
| 71 | + | if widget == nil || widget.GtkWidget() == nil { | |||
| 72 | + | shell.Destroy() | |||
| 73 | + | return nil, fmt.Errorf("failed to wrap popup staging webview widget") | |||
| 74 | + | } | |||
| 75 | + | gtkWidget := widget.GtkWidget() | |||
| 76 | + | widget.SetHexpand(true) | |||
| 77 | + | widget.SetVexpand(true) | |||
| 78 | + | shell.SetContent(gtkWidget) | |||
| 79 | + | return &popupStagingHost{shell: shell, attached: gtkWidget}, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | var _ content.PopupStagingHost = (*popupStagingHost)(nil) | |||
A internal/ui/popup_staging_host_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type popupStagingShellSpy struct { | |
| 12 | + | detachCalls int | |
| 13 | + | destroyCalls int | |
| 14 | + | showCalls int | |
| 15 | + | content *gtk.Widget | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s *popupStagingShellSpy) SetContent(widget *gtk.Widget) { s.content = widget } | |
| 19 | + | func (s *popupStagingShellSpy) DetachContent() *gtk.Widget { | |
| 20 | + | s.detachCalls++ | |
| 21 | + | widget := s.content | |
| 22 | + | s.content = nil | |
| 23 | + | return widget | |
| 24 | + | } | |
| 25 | + | func (s *popupStagingShellSpy) Destroy() { s.destroyCalls++ } | |
| 26 | + | ||
| 27 | + | func TestPopupStagingHostDetachRemovesContentThenDestroysShell(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 29 | + | ||
| 30 | + | widget := >k.Widget{} | |
| 31 | + | shell := &popupStagingShellSpy{content: widget} | |
| 32 | + | host := &popupStagingHost{shell: shell, attached: widget} | |
| 33 | + | ||
| 34 | + | require.NoError(t, host.Detach()) | |
| 35 | + | assert.Equal(t, 1, shell.detachCalls) | |
| 36 | + | assert.Equal(t, 1, shell.destroyCalls) | |
| 37 | + | assert.Nil(t, shell.content) | |
| 38 | + | assert.Nil(t, host.attached) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestPopupStagingHostDestroyIsIdempotentAndNeverShows(t *testing.T) { | |
| 42 | + | t.Parallel() | |
| 43 | + | ||
| 44 | + | shell := &popupStagingShellSpy{content: >k.Widget{}} | |
| 45 | + | host := &popupStagingHost{shell: shell, attached: shell.content} | |
| 46 | + | ||
| 47 | + | host.Destroy() | |
| 48 | + | host.Destroy() | |
| 49 | + | require.NoError(t, host.Detach()) | |
| 50 | + | ||
| 51 | + | assert.Equal(t, 1, shell.detachCalls) | |
| 52 | + | assert.Equal(t, 1, shell.destroyCalls) | |
| 53 | + | assert.Zero(t, shell.showCalls) | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type popupStagingShellSpy struct { | |||
| 12 | + | detachCalls int | |||
| 13 | + | destroyCalls int | |||
| 14 | + | showCalls int | |||
| 15 | + | content *gtk.Widget | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (s *popupStagingShellSpy) SetContent(widget *gtk.Widget) { s.content = widget } | |||
| 19 | + | func (s *popupStagingShellSpy) DetachContent() *gtk.Widget { | |||
| 20 | + | s.detachCalls++ | |||
| 21 | + | widget := s.content | |||
| 22 | + | s.content = nil | |||
| 23 | + | return widget | |||
| 24 | + | } | |||
| 25 | + | func (s *popupStagingShellSpy) Destroy() { s.destroyCalls++ } | |||
| 26 | + | ||||
| 27 | + | func TestPopupStagingHostDetachRemovesContentThenDestroysShell(t *testing.T) { | |||
| 28 | + | t.Parallel() | |||
| 29 | + | ||||
| 30 | + | widget := >k.Widget{} | |||
| 31 | + | shell := &popupStagingShellSpy{content: widget} | |||
| 32 | + | host := &popupStagingHost{shell: shell, attached: widget} | |||
| 33 | + | ||||
| 34 | + | require.NoError(t, host.Detach()) | |||
| 35 | + | assert.Equal(t, 1, shell.detachCalls) | |||
| 36 | + | assert.Equal(t, 1, shell.destroyCalls) | |||
| 37 | + | assert.Nil(t, shell.content) | |||
| 38 | + | assert.Nil(t, host.attached) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestPopupStagingHostDestroyIsIdempotentAndNeverShows(t *testing.T) { | |||
| 42 | + | t.Parallel() | |||
| 43 | + | ||||
| 44 | + | shell := &popupStagingShellSpy{content: >k.Widget{}} | |||
| 45 | + | host := &popupStagingHost{shell: shell, attached: shell.content} | |||
| 46 | + | ||||
| 47 | + | host.Destroy() | |||
| 48 | + | host.Destroy() | |||
| 49 | + | require.NoError(t, host.Detach()) | |||
| 50 | + | ||||
| 51 | + | assert.Equal(t, 1, shell.detachCalls) | |||
| 52 | + | assert.Equal(t, 1, shell.destroyCalls) | |||
| 53 | + | assert.Zero(t, shell.showCalls) | |||
| 54 | + | } | |||