detached 8e76281

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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()) {
823823 }
824824
825825 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 {
826832 if wv == nil {
827- return
833+ return false
828834 }
829835 wv.mu.RLock()
830836 fn := wv.nativePopupHostAbort
831837 wv.mu.RUnlock()
832- if fn != nil {
833- fn()
838+ if fn == nil {
839+ return false
834840 }
841+ fn()
842+ return true
835843 }
836844
837845 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- return833+ return false
828 }834 }
829 wv.mu.RLock()835 wv.mu.RLock()
830 fn := wv.nativePopupHostAbort836 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
587587 }
588588 }
589589
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+
590614 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
591615 switch disposition {
592616 case purecef.WindowOpenDispositionWodCurrentTab:
@@ -616,7 +640,7 @@ func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.Wind
616640 // pane handle the navigation.
617641 func (h *handlerSet) OnBeforePopup(
618642 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,
620644 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
621645 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
622646 ) bool {
@@ -649,6 +673,8 @@ func (h *handlerSet) OnBeforePopup(
649673
650674 dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() {
651675 popup = cb.OnCreate(port.PopupRequest{
676+ Engine: dto.BrowserEngineCEF,
677+ PopupFeatures: mapCEFPopupFeatures(popupFeatures),
652678 TargetURI: targetURL,
653679 FrameName: targetFrameName,
654680 IsUserGesture: userGesture != 0,
@@ -692,32 +718,52 @@ func (h *handlerSet) OnBeforePopup(
692718 Msg("cef: native popup armed")
693719 return false
694720 }
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()
697731 }
698- cefPopup.discardNativePopupCandidate()
699732 logging.FromContext(h.currentContext()).Warn().
700733 Int32("popup_id", popupID).
701734 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")
703737 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()
705745 logging.FromContext(h.currentContext()).Debug().
706746 Int32("popup_id", popupID).
707747 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
708748 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")
710758 return true
711759 default:
712- if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
713- return false
760+ if !cefPopup.preparePopupShellDirectBrowserCreation() {
761+ cefPopup.discardNativePopupCandidate()
714762 }
715-
716- cefPopup.discardNativePopupCandidate()
717763 logging.FromContext(h.currentContext()).Debug().
718764 Int32("popup_id", popupID).
719765 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")
721767 return true
722768 }
723769 }
@@ -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 false719 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 true737 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 true758 return true
711 default:759 default:
712- if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {760+ if !cefPopup.preparePopupShellDirectBrowserCreation() {
713- return false761+ 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 true767 return true
722 }768 }
723 }769 }
M internal/infrastructure/cef/webview_handlers_test.go +245 -0
@@ -229,6 +229,8 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe
229229 rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth")
230230 require.True(t, ok)
231231 require.NotNil(t, rawClient)
232+ require.Equal(t, int32(55), wv.nativePopupID)
233+ require.Same(t, parent, wv.nativePopupParent)
232234 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
233235
234236 wv.updateURI("about:blank")
@@ -262,6 +264,249 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin
262264 require.True(t, wv.isLoading)
263265 }
264266
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+
265510 func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) {
266511 delayed := make(chan func(), 1)
267512 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)
3232 var _ port.DevToolsOpener = (*WebView)(nil)
3333 var _ port.Printer = (*WebView)(nil)
3434 var _ port.PopupLifecycleCapable = (*WebView)(nil)
35+var _ port.PopupFeatureResolver = (*WebView)(nil)
3536 var _ port.OAuthCallbackCapable = (*WebView)(nil)
3637
3738 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -522,6 +523,44 @@ func (wv *WebView) connectCloseSignal() {
522523 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
523524 }
524525
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+
525564 func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
526565 wv.mu.Lock()
527566 wv.browsingContextDecision = decision
@@ -1658,6 +1697,22 @@ func (wv *WebView) State() port.WebViewState {
16581697 }
16591698 }
16601699
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+
16611716 // SetCallbacks registers callback handlers for WebView events.
16621717 // Pass nil to clear all callbacks.
16631718 func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
@@ -1696,13 +1751,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
16961751 wv.OnClose = callbacks.OnClose
16971752 if callbacks.OnCreate != nil {
16981753 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))
17061755 if result == nil {
17071756 return nil
17081757 }
@@ -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 = decision566 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.OnClose1751 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 nil1756 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.
615615 return a.lastFocusedBrowserWindow()
616616 }
617617
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+
618632 func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
619633 bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
620634 if bw == nil {
@@ -3164,6 +3178,7 @@ func (a *App) initCoordinators(ctx context.Context) {
31643178 })
31653179 a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane)
31663180 a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow)
3181+ a.contentCoord.SetOnStagePopup(a.stagePopup)
31673182 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
31683183 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
31693184 a.activateBrowserWindow(bw)
@@ -3176,7 +3191,7 @@ func (a *App) initCoordinators(ctx context.Context) {
31763191 }
31773192 return a.wsCoord.ClosePaneByID(ctx, paneID)
31783193 })
3179- a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow)
3194+ a.contentCoord.SetOnOpenNativePopup(a.openPopupNativeWindow)
31803195 // Wire tabbed popup behavior to create new tabs in the originating window.
31813196 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
31823197
@@ -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 {
3131 policy browsingContextPolicy
3232 namedContexts *namedBrowsingContextRegistry
3333 pendingPopups map[port.WebViewID]*PendingPopup
34+ deferredPopups map[port.WebViewID]*deferredPendingPopup
3435 popupOAuth map[port.WebViewID]*popupOAuthState
3536 popupRefresh map[entity.PaneID]*time.Timer
3637 mu sync.RWMutex
3738 }
3839
40+type deferredPendingPopup struct {
41+ *PendingPopup
42+ Request port.PopupRequest
43+ Decision dto.HostDecision
44+ StagingHost PopupStagingHost
45+ cleanupOnce sync.Once
46+}
47+
3948 type popupOAuthState struct {
4049 ParentPaneID entity.PaneID
4150 ParentURIAtOpen string
@@ -86,6 +95,9 @@ func (pm *popupManager) ensureInitialized() {
8695 if pm.pendingPopups == nil {
8796 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
8897 }
98+ if pm.deferredPopups == nil {
99+ pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup)
100+ }
89101 if pm.namedContexts == nil {
90102 pm.namedContexts = newNamedBrowsingContextRegistry()
91103 }
@@ -356,6 +368,28 @@ func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup,
356368 return pending, ok
357369 }
358370
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+
359393 func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
360394 if pm == nil {
361395 return
@@ -560,8 +594,7 @@ func (pm *popupManager) handlePopupCreate(
560594 case dto.HostDecisionCreateNativePopup:
561595 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
562596 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)
565598 case dto.HostDecisionCreatePane:
566599 // Continue below.
567600 break
@@ -599,6 +632,188 @@ func (pm *popupManager) handlePopupCreate(
599632 })
600633 }
601634
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+
602817 func (pm *popupManager) finishPopupCreate(
603818 ctx context.Context,
604819 hooks popupCoordinatorHooks,
@@ -31,11 +31,20 @@ type popupManager struct {
31 policy browsingContextPolicy31 policy browsingContextPolicy
32 namedContexts *namedBrowsingContextRegistry32 namedContexts *namedBrowsingContextRegistry
33 pendingPopups map[port.WebViewID]*PendingPopup33 pendingPopups map[port.WebViewID]*PendingPopup
34+ deferredPopups map[port.WebViewID]*deferredPendingPopup
34 popupOAuth map[port.WebViewID]*popupOAuthState35 popupOAuth map[port.WebViewID]*popupOAuthState
35 popupRefresh map[entity.PaneID]*time.Timer36 popupRefresh map[entity.PaneID]*time.Timer
36 mu sync.RWMutex37 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.PaneID49 ParentPaneID entity.PaneID
41 ParentURIAtOpen string50 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, ok368 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 return395 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 break600 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) {
12321232 defer c.popups.mu.RUnlock()
12331233 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
12341234 }
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 := &gtk.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: &gtk.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 := &gtk.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: &gtk.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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close