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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 147 | 147 | ||
| 148 | 148 | // HostDecision represents the output of browsing-context policy. | |
| 149 | 149 | type HostDecision struct { | |
| 150 | - | Kind HostDecisionKind | |
| 150 | + | Kind HostDecisionKind | |
| 151 | + | SourceHost SourceHostKind | |
| 151 | 152 | ||
| 152 | 153 | ReuseContextName string | |
| 153 | 154 | 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 HostDecisionKind | 150 | + | Kind HostDecisionKind |
| 151 | + | SourceHost SourceHostKind | |||
| 151 | 152 | ||||
| 152 | ReuseContextName string | 153 | ReuseContextName string | ||
| 153 | BrowsingContextName string | 154 | BrowsingContextName string | ||
M internal/infrastructure/cef/webview_handlers.go +5 -0
| @@ -730,6 +730,11 @@ func (h *handlerSet) OnBeforePopup( | |||
| 730 | 730 | cefPopup.Destroy() | |
| 731 | 731 | } | |
| 732 | 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 | 738 | Int32("popup_id", popupID). | |
| 734 | 739 | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 735 | 740 | 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 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "encoding/json" | |
| 5 | 7 | "sync" | |
| 6 | 8 | "sync/atomic" | |
| 7 | 9 | "testing" | |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | 11 | ||
| 10 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | 13 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 14 | + | "github.com/rs/zerolog" | |
| 12 | 15 | "github.com/stretchr/testify/assert" | |
| 13 | 16 | "github.com/stretchr/testify/mock" | |
| 14 | 17 | "github.com/stretchr/testify/require" | |
| @@ -16,6 +19,7 @@ import ( | |||
| 16 | 19 | "github.com/bnema/dumber/internal/application/dto" | |
| 17 | 20 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 21 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 22 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | 23 | ) | |
| 20 | 24 | ||
| 21 | 25 | type clipboardOrchestratorRecorder struct { | |
| @@ -394,11 +398,14 @@ func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *te | |||
| 394 | 398 | } | |
| 395 | 399 | ||
| 396 | 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 | 405 | popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}} | |
| 399 | 406 | popupWV.markNativePopupCandidate(parentWV) | |
| 400 | 407 | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{ | |
| 401 | - | Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true, | |
| 408 | + | Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true, | |
| 402 | 409 | }) | |
| 403 | 410 | abortCalls := 0 | |
| 404 | 411 | popupWV.SetNativePopupHostAbort(func() { | |
| @@ -417,6 +424,13 @@ func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *te | |||
| 417 | 424 | require.False(t, popupWV.nativePopupFallbackStarted) | |
| 418 | 425 | require.Nil(t, popupWV.popupOpenerBridgeParent) | |
| 419 | 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 | 436 | func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) { | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package cef | 1 | 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 := 0 | 410 | 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{} | |||
| 12 | 12 | ||
| 13 | 13 | func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision { | |
| 14 | 14 | decision := dto.HostDecision{ | |
| 15 | + | SourceHost: req.SourceHost, | |
| 15 | 16 | RequestContextDisposition: req.RequestContextDisposition, | |
| 16 | 17 | RequiresNativeOpener: req.RequiresNativeOpener, | |
| 17 | 18 | } | |
| @@ -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( | |||
| 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 | 635 | func (pm *popupManager) finishPopupCreate( | |
| 818 | 636 | ctx context.Context, | |
| 819 | 637 | 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) { | |||
| 1298 | 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 | 1377 | func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |
| 1302 | 1378 | parent := mocks.NewMockWebView(t) | |
| 1303 | 1379 | 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() | ||