detached 2b549d6

fix(popup): clean deferred contexts and failure logs

commit
2b549d6f8b9023e36e5290ca8ea510b6d97af1f6
author
brice · · 2026-07-25 08:12 UTC
committer
brice · · 2026-07-26 16:42 UTC
parent
8e76281
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +299 -185
M internal/application/dto/browsing_context.go +2 -1
@@ -147,7 +147,8 @@ const (
147147
148148 // HostDecision represents the output of browsing-context policy.
149149 type HostDecision struct {
150- Kind HostDecisionKind
150+ Kind HostDecisionKind
151+ SourceHost SourceHostKind
151152
152153 ReuseContextName string
153154 BrowsingContextName string
@@ -147,7 +147,8 @@ const (
147 147
148 // HostDecision represents the output of browsing-context policy.148 // HostDecision represents the output of browsing-context policy.
149 type HostDecision struct {149 type HostDecision struct {
150- Kind HostDecisionKind150+ Kind HostDecisionKind
151+ SourceHost SourceHostKind
151 152
152 ReuseContextName string153 ReuseContextName string
153 BrowsingContextName string154 BrowsingContextName string
M internal/infrastructure/cef/webview_handlers.go +5 -0
@@ -730,6 +730,11 @@ func (h *handlerSet) OnBeforePopup(
730730 cefPopup.Destroy()
731731 }
732732 logging.FromContext(h.currentContext()).Warn().
733+ Str("engine", string(dto.BrowserEngineCEF)).
734+ Str("source_host", string(decision.SourceHost)).
735+ Str("decision", string(decision.Kind)).
736+ Str("target_disposition", string(mapCEFWindowDisposition(targetDisposition))).
737+ Str("reason_code", string(dto.BrowsingContextFailureNativeArm)).
733738 Int32("popup_id", popupID).
734739 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
735740 Bool("fallback_eligible", fallbackEligible).
@@ -730,6 +730,11 @@ func (h *handlerSet) OnBeforePopup(
730 cefPopup.Destroy()730 cefPopup.Destroy()
731 }731 }
732 logging.FromContext(h.currentContext()).Warn().732 logging.FromContext(h.currentContext()).Warn().
733+ Str("engine", string(dto.BrowserEngineCEF)).
734+ Str("source_host", string(decision.SourceHost)).
735+ Str("decision", string(decision.Kind)).
736+ Str("target_disposition", string(mapCEFWindowDisposition(targetDisposition))).
737+ Str("reason_code", string(dto.BrowsingContextFailureNativeArm)).
733 Int32("popup_id", popupID).738 Int32("popup_id", popupID).
734 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).739 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
735 Bool("fallback_eligible", fallbackEligible).740 Bool("fallback_eligible", fallbackEligible).
M internal/infrastructure/cef/webview_handlers_test.go +16 -2
@@ -1,7 +1,9 @@
11 package cef
22
33 import (
4+ "bytes"
45 "context"
6+ "encoding/json"
57 "sync"
68 "sync/atomic"
79 "testing"
@@ -9,6 +11,7 @@ import (
911
1012 purecef "github.com/bnema/purego-cef/cef"
1113 cefmocks "github.com/bnema/purego-cef/cef/mocks"
14+ "github.com/rs/zerolog"
1215 "github.com/stretchr/testify/assert"
1316 "github.com/stretchr/testify/mock"
1417 "github.com/stretchr/testify/require"
@@ -16,6 +19,7 @@ import (
1619 "github.com/bnema/dumber/internal/application/dto"
1720 "github.com/bnema/dumber/internal/application/port"
1821 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
22+ "github.com/bnema/dumber/internal/logging"
1923 )
2024
2125 type clipboardOrchestratorRecorder struct {
@@ -394,11 +398,14 @@ func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *te
394398 }
395399
396400 func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) {
397- parentWV := &WebView{ctx: context.Background(), id: 19}
401+ var output bytes.Buffer
402+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
403+ ctx := logging.WithContext(context.Background(), logger)
404+ parentWV := &WebView{ctx: ctx, id: 19}
398405 popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}}
399406 popupWV.markNativePopupCandidate(parentWV)
400407 popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
401- Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true,
408+ Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true,
402409 })
403410 abortCalls := 0
404411 popupWV.SetNativePopupHostAbort(func() {
@@ -417,6 +424,13 @@ func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *te
417424 require.False(t, popupWV.nativePopupFallbackStarted)
418425 require.Nil(t, popupWV.popupOpenerBridgeParent)
419426 require.True(t, popupWV.IsDestroyed())
427+ var record map[string]any
428+ require.NoError(t, json.Unmarshal(output.Bytes(), &record))
429+ assert.Equal(t, "cef", record["engine"])
430+ assert.Equal(t, "floating", record["source_host"])
431+ assert.Equal(t, "create-native-popup", record["decision"])
432+ assert.Equal(t, "new-popup", record["target_disposition"])
433+ assert.Equal(t, "native-arm-failed", record["reason_code"])
420434 }
421435
422436 func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) {
@@ -1,7 +1,9 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "encoding/json"
5 "sync"7 "sync"
6 "sync/atomic"8 "sync/atomic"
7 "testing"9 "testing"
@@ -9,6 +11,7 @@ import (
9 11
10 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
11 cefmocks "github.com/bnema/purego-cef/cef/mocks"13 cefmocks "github.com/bnema/purego-cef/cef/mocks"
14+ "github.com/rs/zerolog"
12 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
@@ -16,6 +19,7 @@ import (
16 "github.com/bnema/dumber/internal/application/dto"19 "github.com/bnema/dumber/internal/application/dto"
17 "github.com/bnema/dumber/internal/application/port"20 "github.com/bnema/dumber/internal/application/port"
18 portmocks "github.com/bnema/dumber/internal/application/port/mocks"21 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
22+ "github.com/bnema/dumber/internal/logging"
19 )23 )
20 24
21 type clipboardOrchestratorRecorder struct {25 type clipboardOrchestratorRecorder struct {
@@ -394,11 +398,14 @@ func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *te
394 }398 }
395 399
396 func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) {400 func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) {
397- parentWV := &WebView{ctx: context.Background(), id: 19}401+ var output bytes.Buffer
402+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
403+ ctx := logging.WithContext(context.Background(), logger)
404+ parentWV := &WebView{ctx: ctx, id: 19}
398 popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}}405 popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}}
399 popupWV.markNativePopupCandidate(parentWV)406 popupWV.markNativePopupCandidate(parentWV)
400 popupWV.SetBrowsingContextHostDecision(dto.HostDecision{407 popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
401- Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true,408+ Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true,
402 })409 })
403 abortCalls := 0410 abortCalls := 0
404 popupWV.SetNativePopupHostAbort(func() {411 popupWV.SetNativePopupHostAbort(func() {
@@ -417,6 +424,13 @@ func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *te
417 require.False(t, popupWV.nativePopupFallbackStarted)424 require.False(t, popupWV.nativePopupFallbackStarted)
418 require.Nil(t, popupWV.popupOpenerBridgeParent)425 require.Nil(t, popupWV.popupOpenerBridgeParent)
419 require.True(t, popupWV.IsDestroyed())426 require.True(t, popupWV.IsDestroyed())
427+ var record map[string]any
428+ require.NoError(t, json.Unmarshal(output.Bytes(), &record))
429+ assert.Equal(t, "cef", record["engine"])
430+ assert.Equal(t, "floating", record["source_host"])
431+ assert.Equal(t, "create-native-popup", record["decision"])
432+ assert.Equal(t, "new-popup", record["target_disposition"])
433+ assert.Equal(t, "native-arm-failed", record["reason_code"])
420 }434 }
421 435
422 func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) {436 func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) {
M internal/ui/coordinator/content/browsing_context_policy.go +1 -0
@@ -12,6 +12,7 @@ type browsingContextPolicy struct{}
1212
1313 func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
1414 decision := dto.HostDecision{
15+ SourceHost: req.SourceHost,
1516 RequestContextDisposition: req.RequestContextDisposition,
1617 RequiresNativeOpener: req.RequiresNativeOpener,
1718 }
@@ -12,6 +12,7 @@ type browsingContextPolicy struct{}
12 12
13 func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {13 func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
14 decision := dto.HostDecision{14 decision := dto.HostDecision{
15+ SourceHost: req.SourceHost,
15 RequestContextDisposition: req.RequestContextDisposition,16 RequestContextDisposition: req.RequestContextDisposition,
16 RequiresNativeOpener: req.RequiresNativeOpener,17 RequiresNativeOpener: req.RequiresNativeOpener,
17 }18 }
A internal/ui/coordinator/content/popup_deferred.go +199 -0
@@ -0,0 +1,199 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+func (pm *popupManager) awaitPopupFeatures(
15+ ctx context.Context,
16+ hooks popupCoordinatorHooks,
17+ parentPaneID entity.PaneID,
18+ parentWebViewID port.WebViewID,
19+ parentURIAtOpen string,
20+ req port.PopupRequest,
21+ decision dto.HostDecision,
22+) port.WebView {
23+ log := logging.FromContext(ctx)
24+ normalized := buildPopupBrowsingContextRequest(req)
25+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
26+ popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, false)
27+ if err != nil {
28+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
29+ return nil
30+ }
31+ resolver, resolverOK := popupWV.(port.PopupFeatureResolver)
32+ lifecycle, lifecycleOK := popupWV.(port.PopupLifecycleCapable)
33+ if !resolverOK || !lifecycleOK || pm.onStagePopup == nil {
34+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
35+ popupWV.Destroy()
36+ return nil
37+ }
38+ staging, err := pm.onStagePopup(ctx, StagePopupInput{PopupWebView: popupWV})
39+ if err != nil || staging == nil {
40+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
41+ popupWV.Destroy()
42+ return nil
43+ }
44+ pm.setBrowsingContextDecision(popupWV, decision)
45+ paneID, _ := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
46+ pending := &deferredPendingPopup{
47+ PendingPopup: &PendingPopup{
48+ PaneID: paneID, WebView: popupWV, ParentPaneID: parentPaneID,
49+ ParentWebViewID: parentWebViewID, TargetURI: req.TargetURI, FrameName: req.FrameName,
50+ IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(),
51+ },
52+ Request: req, Decision: decision, StagingHost: staging,
53+ }
54+ pm.storeDeferredPopup(popupWV.ID(), pending)
55+ callbackCtx := logging.WithContext(context.Background(), *log)
56+ lifecycle.SetOnReadyToShow(func() {
57+ pm.resolvePendingPopupFeatures(callbackCtx, hooks, popupWV.ID(), parentURIAtOpen, resolver)
58+ })
59+ lifecycle.SetOnClose(func() {
60+ pm.failPendingPopup(callbackCtx, popupWV.ID(), dto.BrowsingContextFailureHostFailed, nil)
61+ })
62+ return popupWV
63+}
64+
65+func (pm *popupManager) cleanupDeferredPending(pending *deferredPendingPopup) {
66+ if pending == nil {
67+ return
68+ }
69+ pending.cleanupOnce.Do(func() {
70+ if pending.StagingHost != nil {
71+ pending.StagingHost.Destroy()
72+ }
73+ if pending.WebView != nil {
74+ pending.WebView.Destroy()
75+ }
76+ })
77+}
78+
79+func (pm *popupManager) failPendingPopup(
80+ ctx context.Context,
81+ popupID port.WebViewID,
82+ code dto.BrowsingContextFailureCode,
83+ err error,
84+) {
85+ pending, ok := pm.takeDeferredPopup(popupID)
86+ if !ok || pending == nil {
87+ return
88+ }
89+ req := buildPopupBrowsingContextRequest(pending.Request)
90+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
91+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, code, err)
92+ pm.cleanupDeferredPending(pending)
93+}
94+
95+func (pm *popupManager) resolvePendingPopupFeatures(
96+ ctx context.Context,
97+ hooks popupCoordinatorHooks,
98+ popupID port.WebViewID,
99+ parentURIAtOpen string,
100+ resolver port.PopupFeatureResolver,
101+) {
102+ pending, ok := pm.takeDeferredPopup(popupID)
103+ if !ok || pending == nil {
104+ return
105+ }
106+ resolved := resolver.ResolvePopupFeatures()
107+ if resolved.State == dto.PopupFeaturesUnknown {
108+ req := buildPopupBrowsingContextRequest(pending.Request)
109+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
110+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureFeatureResolution, nil)
111+ pm.cleanupDeferredPending(pending)
112+ return
113+ }
114+ if err := pending.StagingHost.Detach(); err != nil {
115+ req := buildPopupBrowsingContextRequest(pending.Request)
116+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
117+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureStagingDetach, err)
118+ pm.cleanupDeferredPending(pending)
119+ return
120+ }
121+
122+ request := pending.Request
123+ request.PopupFeatures = resolved
124+ normalized := buildPopupBrowsingContextRequest(request)
125+ normalized.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
126+ namedContextExists := false
127+ if !request.NoJavaScriptAccess {
128+ _, namedContextExists = pm.lookupReusableNamedPopup(pending.ParentPaneID, request.FrameName, hooks)
129+ }
130+ decision := pm.policy.Decide(normalized, namedContextExists)
131+ logBrowsingContextDecision(*logging.FromContext(ctx), normalized, decision)
132+ pm.setBrowsingContextDecision(pending.WebView, decision)
133+
134+ transferred := false
135+ switch decision.Kind {
136+ case dto.HostDecisionCreateBrowserWindow:
137+ transferred = pm.openExistingPopupInBrowserWindow(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, pending.WebView, request, decision, true)
138+ case dto.HostDecisionCreateNativePopup:
139+ transferred = pm.openExistingPopupInNativePopup(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, parentURIAtOpen, pending.WebView, request, decision)
140+ case dto.HostDecisionReuseNamedPane:
141+ // Reuse transfers no ownership: the existing context is navigated while
142+ // the newly staged WebView remains ours and must be discarded below.
143+ _, _ = pm.reuseNamedPopup(ctx, hooks, pending.ParentPaneID, request.FrameName, request.TargetURI)
144+ default:
145+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureFeatureResolution, nil)
146+ }
147+ if !transferred {
148+ pm.cleanupDeferredPending(pending)
149+ }
150+}
151+
152+func (pm *popupManager) openExistingPopupInNativePopup(
153+ ctx context.Context,
154+ hooks popupCoordinatorHooks,
155+ parentPaneID entity.PaneID,
156+ parentWebViewID port.WebViewID,
157+ parentURIAtOpen string,
158+ popupWV port.WebView,
159+ req port.PopupRequest,
160+ decision dto.HostDecision,
161+) bool {
162+ log := logging.FromContext(ctx)
163+ normalized := buildPopupBrowsingContextRequest(req)
164+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
165+ if popupWV == nil || pm.onOpenNativePopup == nil {
166+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
167+ return false
168+ }
169+ cfg := pm.currentPopupConfig()
170+ allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener &&
171+ (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV))
172+ var abortOnce sync.Once
173+ abortResult := false
174+ onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
175+ abortOnce.Do(func() {
176+ if !allowFallback {
177+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
178+ return
179+ }
180+ abortResult = pm.openExistingPopupInBrowserWindow(
181+ abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true,
182+ )
183+ })
184+ return abortResult
185+ }
186+ if err := pm.onOpenNativePopup(ctx, NativePopupInput{
187+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen,
188+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req,
189+ ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
190+ AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort,
191+ }); err != nil {
192+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
193+ return false
194+ }
195+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
196+ lifecycle.PrimePopupNavigation(req.TargetURI)
197+ }
198+ return true
199+}
@@ -0,0 +1,199 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+func (pm *popupManager) awaitPopupFeatures(
15+ ctx context.Context,
16+ hooks popupCoordinatorHooks,
17+ parentPaneID entity.PaneID,
18+ parentWebViewID port.WebViewID,
19+ parentURIAtOpen string,
20+ req port.PopupRequest,
21+ decision dto.HostDecision,
22+) port.WebView {
23+ log := logging.FromContext(ctx)
24+ normalized := buildPopupBrowsingContextRequest(req)
25+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
26+ popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, false)
27+ if err != nil {
28+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
29+ return nil
30+ }
31+ resolver, resolverOK := popupWV.(port.PopupFeatureResolver)
32+ lifecycle, lifecycleOK := popupWV.(port.PopupLifecycleCapable)
33+ if !resolverOK || !lifecycleOK || pm.onStagePopup == nil {
34+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
35+ popupWV.Destroy()
36+ return nil
37+ }
38+ staging, err := pm.onStagePopup(ctx, StagePopupInput{PopupWebView: popupWV})
39+ if err != nil || staging == nil {
40+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
41+ popupWV.Destroy()
42+ return nil
43+ }
44+ pm.setBrowsingContextDecision(popupWV, decision)
45+ paneID, _ := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
46+ pending := &deferredPendingPopup{
47+ PendingPopup: &PendingPopup{
48+ PaneID: paneID, WebView: popupWV, ParentPaneID: parentPaneID,
49+ ParentWebViewID: parentWebViewID, TargetURI: req.TargetURI, FrameName: req.FrameName,
50+ IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(),
51+ },
52+ Request: req, Decision: decision, StagingHost: staging,
53+ }
54+ pm.storeDeferredPopup(popupWV.ID(), pending)
55+ callbackCtx := logging.WithContext(context.Background(), *log)
56+ lifecycle.SetOnReadyToShow(func() {
57+ pm.resolvePendingPopupFeatures(callbackCtx, hooks, popupWV.ID(), parentURIAtOpen, resolver)
58+ })
59+ lifecycle.SetOnClose(func() {
60+ pm.failPendingPopup(callbackCtx, popupWV.ID(), dto.BrowsingContextFailureHostFailed, nil)
61+ })
62+ return popupWV
63+}
64+
65+func (pm *popupManager) cleanupDeferredPending(pending *deferredPendingPopup) {
66+ if pending == nil {
67+ return
68+ }
69+ pending.cleanupOnce.Do(func() {
70+ if pending.StagingHost != nil {
71+ pending.StagingHost.Destroy()
72+ }
73+ if pending.WebView != nil {
74+ pending.WebView.Destroy()
75+ }
76+ })
77+}
78+
79+func (pm *popupManager) failPendingPopup(
80+ ctx context.Context,
81+ popupID port.WebViewID,
82+ code dto.BrowsingContextFailureCode,
83+ err error,
84+) {
85+ pending, ok := pm.takeDeferredPopup(popupID)
86+ if !ok || pending == nil {
87+ return
88+ }
89+ req := buildPopupBrowsingContextRequest(pending.Request)
90+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
91+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, code, err)
92+ pm.cleanupDeferredPending(pending)
93+}
94+
95+func (pm *popupManager) resolvePendingPopupFeatures(
96+ ctx context.Context,
97+ hooks popupCoordinatorHooks,
98+ popupID port.WebViewID,
99+ parentURIAtOpen string,
100+ resolver port.PopupFeatureResolver,
101+) {
102+ pending, ok := pm.takeDeferredPopup(popupID)
103+ if !ok || pending == nil {
104+ return
105+ }
106+ resolved := resolver.ResolvePopupFeatures()
107+ if resolved.State == dto.PopupFeaturesUnknown {
108+ req := buildPopupBrowsingContextRequest(pending.Request)
109+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
110+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureFeatureResolution, nil)
111+ pm.cleanupDeferredPending(pending)
112+ return
113+ }
114+ if err := pending.StagingHost.Detach(); err != nil {
115+ req := buildPopupBrowsingContextRequest(pending.Request)
116+ req.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
117+ logBrowsingContextFailure(*logging.FromContext(ctx), req, pending.Decision, dto.BrowsingContextFailureStagingDetach, err)
118+ pm.cleanupDeferredPending(pending)
119+ return
120+ }
121+
122+ request := pending.Request
123+ request.PopupFeatures = resolved
124+ normalized := buildPopupBrowsingContextRequest(request)
125+ normalized.SourceHost = pm.resolveSourceHost(pending.ParentPaneID)
126+ namedContextExists := false
127+ if !request.NoJavaScriptAccess {
128+ _, namedContextExists = pm.lookupReusableNamedPopup(pending.ParentPaneID, request.FrameName, hooks)
129+ }
130+ decision := pm.policy.Decide(normalized, namedContextExists)
131+ logBrowsingContextDecision(*logging.FromContext(ctx), normalized, decision)
132+ pm.setBrowsingContextDecision(pending.WebView, decision)
133+
134+ transferred := false
135+ switch decision.Kind {
136+ case dto.HostDecisionCreateBrowserWindow:
137+ transferred = pm.openExistingPopupInBrowserWindow(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, pending.WebView, request, decision, true)
138+ case dto.HostDecisionCreateNativePopup:
139+ transferred = pm.openExistingPopupInNativePopup(ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, parentURIAtOpen, pending.WebView, request, decision)
140+ case dto.HostDecisionReuseNamedPane:
141+ // Reuse transfers no ownership: the existing context is navigated while
142+ // the newly staged WebView remains ours and must be discarded below.
143+ _, _ = pm.reuseNamedPopup(ctx, hooks, pending.ParentPaneID, request.FrameName, request.TargetURI)
144+ default:
145+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureFeatureResolution, nil)
146+ }
147+ if !transferred {
148+ pm.cleanupDeferredPending(pending)
149+ }
150+}
151+
152+func (pm *popupManager) openExistingPopupInNativePopup(
153+ ctx context.Context,
154+ hooks popupCoordinatorHooks,
155+ parentPaneID entity.PaneID,
156+ parentWebViewID port.WebViewID,
157+ parentURIAtOpen string,
158+ popupWV port.WebView,
159+ req port.PopupRequest,
160+ decision dto.HostDecision,
161+) bool {
162+ log := logging.FromContext(ctx)
163+ normalized := buildPopupBrowsingContextRequest(req)
164+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
165+ if popupWV == nil || pm.onOpenNativePopup == nil {
166+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
167+ return false
168+ }
169+ cfg := pm.currentPopupConfig()
170+ allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener &&
171+ (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV))
172+ var abortOnce sync.Once
173+ abortResult := false
174+ onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
175+ abortOnce.Do(func() {
176+ if !allowFallback {
177+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
178+ return
179+ }
180+ abortResult = pm.openExistingPopupInBrowserWindow(
181+ abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true,
182+ )
183+ })
184+ return abortResult
185+ }
186+ if err := pm.onOpenNativePopup(ctx, NativePopupInput{
187+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen,
188+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req,
189+ ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
190+ AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort,
191+ }); err != nil {
192+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
193+ return false
194+ }
195+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
196+ lifecycle.PrimePopupNavigation(req.TargetURI)
197+ }
198+ return true
199+}
M internal/ui/coordinator/content/popup_manager.go +0 -182
@@ -632,188 +632,6 @@ func (pm *popupManager) handlePopupCreate(
632632 })
633633 }
634634
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-
817635 func (pm *popupManager) finishPopupCreate(
818636 ctx context.Context,
819637 hooks popupCoordinatorHooks,
@@ -632,188 +632,6 @@ func (pm *popupManager) handlePopupCreate(
632 })632 })
633 }633 }
634 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-
817 func (pm *popupManager) finishPopupCreate(635 func (pm *popupManager) finishPopupCreate(
818 ctx context.Context,636 ctx context.Context,
819 hooks popupCoordinatorHooks,637 hooks popupCoordinatorHooks,
M internal/ui/coordinator/content/popup_test.go +76 -0
@@ -1298,6 +1298,82 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
12981298 popup.ready()
12991299 }
13001300
1301+func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
1302+ ctx := context.Background()
1303+ parentPaneID := entity.PaneID("floating")
1304+ existingPaneID := entity.PaneID("existing")
1305+ parent := mocks.NewMockWebView(t)
1306+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1307+ staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1308+ staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
1309+ staged.EXPECT().Destroy().Once()
1310+ existing := mocks.NewMockWebView(t)
1311+ existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
1312+ existing.EXPECT().IsDestroyed().Return(false).Twice()
1313+ existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
1314+ factory := mocks.NewMockWebViewFactory(t)
1315+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
1316+ staging := &popupStagingHostStub{}
1317+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
1318+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1319+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1320+ c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
1321+ windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
1322+ windowID, ok := windowByPane[paneID]
1323+ return windowID, ok
1324+ })
1325+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1326+
1327+ require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
1328+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
1329+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1330+ }))
1331+ c.RegisterPopupWebView(existingPaneID, existing)
1332+ c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
1333+ staged.ready()
1334+ staged.ready()
1335+
1336+ assert.True(t, staging.detached)
1337+ assert.Equal(t, 1, staging.destroyCalls)
1338+}
1339+
1340+func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
1341+ var output bytes.Buffer
1342+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
1343+ ctx := logging.WithContext(context.Background(), logger)
1344+ parent := mocks.NewMockWebView(t)
1345+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1346+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
1347+ popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
1348+ popup.EXPECT().Destroy().Once()
1349+ factory := mocks.NewMockWebViewFactory(t)
1350+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1351+ c := &Coordinator{popups: newPopupManager()}
1352+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1353+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1354+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
1355+ c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
1356+ assert.False(t, input.AllowBrowserWindowFallback)
1357+ if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
1358+ input.PopupWebView.Destroy()
1359+ }
1360+ return nil
1361+ })
1362+
1363+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1364+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
1365+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1366+ }))
1367+ popup.ready()
1368+
1369+ record := decodeBrowsingContextLog(t, output.Bytes())
1370+ assert.Equal(t, "webkit", record["engine"])
1371+ assert.Equal(t, "floating", record["source_host"])
1372+ assert.Equal(t, "create-native-popup", record["decision"])
1373+ assert.Equal(t, "new-popup", record["target_disposition"])
1374+ assert.Equal(t, "native-arm-failed", record["reason_code"])
1375+}
1376+
13011377 func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
13021378 parent := mocks.NewMockWebView(t)
13031379 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
@@ -1298,6 +1298,82 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
1298 popup.ready()1298 popup.ready()
1299 }1299 }
1300 1300
1301+func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
1302+ ctx := context.Background()
1303+ parentPaneID := entity.PaneID("floating")
1304+ existingPaneID := entity.PaneID("existing")
1305+ parent := mocks.NewMockWebView(t)
1306+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1307+ staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1308+ staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
1309+ staged.EXPECT().Destroy().Once()
1310+ existing := mocks.NewMockWebView(t)
1311+ existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
1312+ existing.EXPECT().IsDestroyed().Return(false).Twice()
1313+ existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
1314+ factory := mocks.NewMockWebViewFactory(t)
1315+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
1316+ staging := &popupStagingHostStub{}
1317+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
1318+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1319+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1320+ c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
1321+ windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
1322+ windowID, ok := windowByPane[paneID]
1323+ return windowID, ok
1324+ })
1325+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1326+
1327+ require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
1328+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
1329+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1330+ }))
1331+ c.RegisterPopupWebView(existingPaneID, existing)
1332+ c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
1333+ staged.ready()
1334+ staged.ready()
1335+
1336+ assert.True(t, staging.detached)
1337+ assert.Equal(t, 1, staging.destroyCalls)
1338+}
1339+
1340+func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
1341+ var output bytes.Buffer
1342+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
1343+ ctx := logging.WithContext(context.Background(), logger)
1344+ parent := mocks.NewMockWebView(t)
1345+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1346+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
1347+ popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
1348+ popup.EXPECT().Destroy().Once()
1349+ factory := mocks.NewMockWebViewFactory(t)
1350+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1351+ c := &Coordinator{popups: newPopupManager()}
1352+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1353+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1354+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
1355+ c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
1356+ assert.False(t, input.AllowBrowserWindowFallback)
1357+ if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
1358+ input.PopupWebView.Destroy()
1359+ }
1360+ return nil
1361+ })
1362+
1363+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1364+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
1365+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1366+ }))
1367+ popup.ready()
1368+
1369+ record := decodeBrowsingContextLog(t, output.Bytes())
1370+ assert.Equal(t, "webkit", record["engine"])
1371+ assert.Equal(t, "floating", record["source_host"])
1372+ assert.Equal(t, "create-native-popup", record["decision"])
1373+ assert.Equal(t, "new-popup", record["target_disposition"])
1374+ assert.Equal(t, "native-arm-failed", record["reason_code"])
1375+}
1376+
1301 func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {1377 func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
1302 parent := mocks.NewMockWebView(t)1378 parent := mocks.NewMockWebView(t)
1303 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()1379 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()

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