detached d0ff807

Separate browsing context DTOs and harden popup lifecycle

Reason:
The browsing-context seam was still carrying concrete request and decision types inside the port package even though this branch now depends on those types across UI and engine integrations. At the same time, the remaining native-popup follow-ups still needed tighter lifecycle handling around OAuth auto-close scheduling and popup shell setup failures.

Scope:
- move browsing-context request and decision types into internal/application/dto
- keep internal/application/port/browsing_context.go interface-only and update dependent signatures/usages
- harden native popup auth close scheduling and popup setup failure cleanup
- tighten popup regression tests and rerun mockery with the repo config

Description:
This commit introduces dto-based browsing-context request and decision types, rewires the CEF, WebKit, and coordinator call sites to use them, and leaves the port seam as interfaces only. It also makes native popup OAuth close scheduling one-shot, destroys popup webviews when popup shell creation fails before attach, and strengthens popup tests so the intended native branch is asserted explicitly.
commit
d0ff807740a4209881cfc244991407bb5c4a025c
author
brice · · 2026-05-13 09:10 UTC
parent
cf66830
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +303 -211
A internal/application/dto/browsing_context.go +84 -0
@@ -0,0 +1,84 @@
1+package dto
2+
3+// WindowDisposition describes how the browsing context should be presented
4+// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
5+// dispositions at a normalized product seam.
6+type WindowDisposition string
7+
8+const (
9+ // WindowDispositionCurrentTab replaces the current document in the same browsing context.
10+ WindowDispositionCurrentTab WindowDisposition = "current-tab"
11+ // WindowDispositionNewTab opens in a new tab-like context.
12+ WindowDispositionNewTab WindowDisposition = "new-tab"
13+ // WindowDispositionNewPopup opens in a popup-style context.
14+ WindowDispositionNewPopup WindowDisposition = "new-popup"
15+ // WindowDispositionNewWindow opens in a new top-level window.
16+ WindowDispositionNewWindow WindowDisposition = "new-window"
17+)
18+
19+// TriggerKind categorizes what triggered a browsing-context request.
20+type TriggerKind string
21+
22+const (
23+ TriggerUnknown TriggerKind = "unknown"
24+ TriggerLinkNewPage TriggerKind = "link-new-page"
25+ TriggerScriptWindowOpen TriggerKind = "script-window-open"
26+ TriggerNamedTargetNavigation TriggerKind = "named-target-navigation"
27+ TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
28+)
29+
30+// RequestContextDisposition makes request-context inheritance explicit at the
31+// policy seam without leaking infrastructure request-context types upward.
32+type RequestContextDisposition string
33+
34+const (
35+ // RequestContextInheritParent preserves the parent request context/session.
36+ RequestContextInheritParent RequestContextDisposition = "inherit-parent"
37+ // RequestContextIsolate starts from a fresh unrelated request context.
38+ RequestContextIsolate RequestContextDisposition = "isolate"
39+)
40+
41+// NewBrowsingContextRequest is the normalized request captured from engine/UI
42+// signals before policy decides how it should be hosted.
43+type NewBrowsingContextRequest struct {
44+ ParentWebViewID uint64
45+
46+ SourceBrowserID int32
47+ SourceFrameID string
48+ SourceFrameURL string
49+
50+ TargetURI string
51+ TargetFrameName string
52+ TargetDisposition WindowDisposition
53+
54+ IsUserGesture bool
55+ NoJavaScriptAccess bool
56+ WindowFeatures string
57+
58+ TriggerKind TriggerKind
59+ AuthIntent bool
60+ RequiresNativeOpener bool
61+ RequestContextDisposition RequestContextDisposition
62+}
63+
64+// HostDecisionKind categorizes the approved host choices for a new browsing context.
65+type HostDecisionKind string
66+
67+const (
68+ HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
69+ HostDecisionCreatePane HostDecisionKind = "create-pane"
70+ HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
71+ HostDecisionDeny HostDecisionKind = "deny"
72+)
73+
74+// HostDecision represents the output of browsing-context policy.
75+type HostDecision struct {
76+ Kind HostDecisionKind
77+
78+ ReuseContextName string
79+ BrowsingContextName string
80+
81+ RequestContextDisposition RequestContextDisposition
82+ RequiresNativeOpener bool
83+ Reason string
84+}
@@ -0,0 +1,84 @@
1+package dto
2+
3+// WindowDisposition describes how the browsing context should be presented
4+// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
5+// dispositions at a normalized product seam.
6+type WindowDisposition string
7+
8+const (
9+ // WindowDispositionCurrentTab replaces the current document in the same browsing context.
10+ WindowDispositionCurrentTab WindowDisposition = "current-tab"
11+ // WindowDispositionNewTab opens in a new tab-like context.
12+ WindowDispositionNewTab WindowDisposition = "new-tab"
13+ // WindowDispositionNewPopup opens in a popup-style context.
14+ WindowDispositionNewPopup WindowDisposition = "new-popup"
15+ // WindowDispositionNewWindow opens in a new top-level window.
16+ WindowDispositionNewWindow WindowDisposition = "new-window"
17+)
18+
19+// TriggerKind categorizes what triggered a browsing-context request.
20+type TriggerKind string
21+
22+const (
23+ TriggerUnknown TriggerKind = "unknown"
24+ TriggerLinkNewPage TriggerKind = "link-new-page"
25+ TriggerScriptWindowOpen TriggerKind = "script-window-open"
26+ TriggerNamedTargetNavigation TriggerKind = "named-target-navigation"
27+ TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
28+)
29+
30+// RequestContextDisposition makes request-context inheritance explicit at the
31+// policy seam without leaking infrastructure request-context types upward.
32+type RequestContextDisposition string
33+
34+const (
35+ // RequestContextInheritParent preserves the parent request context/session.
36+ RequestContextInheritParent RequestContextDisposition = "inherit-parent"
37+ // RequestContextIsolate starts from a fresh unrelated request context.
38+ RequestContextIsolate RequestContextDisposition = "isolate"
39+)
40+
41+// NewBrowsingContextRequest is the normalized request captured from engine/UI
42+// signals before policy decides how it should be hosted.
43+type NewBrowsingContextRequest struct {
44+ ParentWebViewID uint64
45+
46+ SourceBrowserID int32
47+ SourceFrameID string
48+ SourceFrameURL string
49+
50+ TargetURI string
51+ TargetFrameName string
52+ TargetDisposition WindowDisposition
53+
54+ IsUserGesture bool
55+ NoJavaScriptAccess bool
56+ WindowFeatures string
57+
58+ TriggerKind TriggerKind
59+ AuthIntent bool
60+ RequiresNativeOpener bool
61+ RequestContextDisposition RequestContextDisposition
62+}
63+
64+// HostDecisionKind categorizes the approved host choices for a new browsing context.
65+type HostDecisionKind string
66+
67+const (
68+ HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
69+ HostDecisionCreatePane HostDecisionKind = "create-pane"
70+ HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
71+ HostDecisionDeny HostDecisionKind = "deny"
72+)
73+
74+// HostDecision represents the output of browsing-context policy.
75+type HostDecision struct {
76+ Kind HostDecisionKind
77+
78+ ReuseContextName string
79+ BrowsingContextName string
80+
81+ RequestContextDisposition RequestContextDisposition
82+ RequiresNativeOpener bool
83+ Reason string
84+}
M internal/application/port/browsing_context.go +4 -87
@@ -1,91 +1,8 @@
11 // Package port defines interfaces (ports) for use cases to depend on.
2-// These types are the active seam for the approved browsing-context architecture
3-// shared across policy, UI, and engine integrations.
4-
2+// Concrete browsing-context DTOs live in internal/application/dto.
53 package port
64
7-// WindowDisposition describes how the browsing context should be presented
8-// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
9-// dispositions at a normalized product seam.
10-type WindowDisposition string
11-
12-const (
13- // WindowDispositionCurrentTab replaces the current document in the same browsing context.
14- WindowDispositionCurrentTab WindowDisposition = "current-tab"
15- // WindowDispositionNewTab opens in a new tab-like context.
16- WindowDispositionNewTab WindowDisposition = "new-tab"
17- // WindowDispositionNewPopup opens in a popup-style context.
18- WindowDispositionNewPopup WindowDisposition = "new-popup"
19- // WindowDispositionNewWindow opens in a new top-level window.
20- WindowDispositionNewWindow WindowDisposition = "new-window"
21-)
22-
23-// TriggerKind categorizes what triggered a browsing-context request.
24-type TriggerKind string
25-
26-const (
27- TriggerUnknown TriggerKind = "unknown"
28- TriggerLinkNewPage TriggerKind = "link-new-page"
29- TriggerScriptWindowOpen TriggerKind = "script-window-open"
30- TriggerNamedTargetNavigation TriggerKind = "named-target-navigation"
31- TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
32-)
33-
34-// RequestContextDisposition makes request-context inheritance explicit at the
35-// policy seam without leaking infrastructure request-context types upward.
36-type RequestContextDisposition string
37-
38-const (
39- // RequestContextInheritParent preserves the parent request context/session.
40- RequestContextInheritParent RequestContextDisposition = "inherit-parent"
41- // RequestContextIsolate starts from a fresh unrelated request context.
42- RequestContextIsolate RequestContextDisposition = "isolate"
43-)
44-
45-// NewBrowsingContextRequest is the normalized request captured from engine/UI
46-// signals before policy decides how it should be hosted.
47-type NewBrowsingContextRequest struct {
48- ParentWebViewID WebViewID
49-
50- SourceBrowserID int32
51- SourceFrameID string
52- SourceFrameURL string
53-
54- TargetURI string
55- TargetFrameName string
56- TargetDisposition WindowDisposition
57-
58- IsUserGesture bool
59- NoJavaScriptAccess bool
60- WindowFeatures string
61-
62- TriggerKind TriggerKind
63- AuthIntent bool
64- RequiresNativeOpener bool
65- RequestContextDisposition RequestContextDisposition
66-}
67-
68-// HostDecisionKind categorizes the approved host choices for a new browsing context.
69-type HostDecisionKind string
70-
71-const (
72- HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
73- HostDecisionCreatePane HostDecisionKind = "create-pane"
74- HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
75- HostDecisionDeny HostDecisionKind = "deny"
76-)
77-
78-// HostDecision represents the output of browsing-context policy.
79-type HostDecision struct {
80- Kind HostDecisionKind
81-
82- ReuseContextName string
83- BrowsingContextName string
84-
85- RequestContextDisposition RequestContextDisposition
86- RequiresNativeOpener bool
87- Reason string
88-}
5+import "github.com/bnema/dumber/internal/application/dto"
896
907 // PaneHostedBrowsingContextCapable is implemented by engines that need an
918 // explicit transition from a related/native-popup shell into a pane-hosted
@@ -98,8 +15,8 @@ type PaneHostedBrowsingContextCapable interface {
9815 // host decision to an engine webview so lower-level popup handlers can honor
9916 // pane-vs-native behavior synchronously.
10017 type BrowsingContextHostDecisionCapable interface {
101- SetBrowsingContextHostDecision(decision HostDecision)
102- BrowsingContextHostDecision() (HostDecision, bool)
18+ SetBrowsingContextHostDecision(decision dto.HostDecision)
19+ BrowsingContextHostDecision() (dto.HostDecision, bool)
10320 }
10421
10522 // NativePopupHostAbortCapable lets the engine-level popup seam abort an
@@ -1,91 +1,8 @@
1 // Package port defines interfaces (ports) for use cases to depend on.1 // Package port defines interfaces (ports) for use cases to depend on.
2-// These types are the active seam for the approved browsing-context architecture2+// Concrete browsing-context DTOs live in internal/application/dto.
3-// shared across policy, UI, and engine integrations.
4-
5 package port3 package port
6 4
7-// WindowDisposition describes how the browsing context should be presented5+import "github.com/bnema/dumber/internal/application/dto"
8-// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation
9-// dispositions at a normalized product seam.
10-type WindowDisposition string
11-
12-const (
13- // WindowDispositionCurrentTab replaces the current document in the same browsing context.
14- WindowDispositionCurrentTab WindowDisposition = "current-tab"
15- // WindowDispositionNewTab opens in a new tab-like context.
16- WindowDispositionNewTab WindowDisposition = "new-tab"
17- // WindowDispositionNewPopup opens in a popup-style context.
18- WindowDispositionNewPopup WindowDisposition = "new-popup"
19- // WindowDispositionNewWindow opens in a new top-level window.
20- WindowDispositionNewWindow WindowDisposition = "new-window"
21-)
22-
23-// TriggerKind categorizes what triggered a browsing-context request.
24-type TriggerKind string
25-
26-const (
27- TriggerUnknown TriggerKind = "unknown"
28- TriggerLinkNewPage TriggerKind = "link-new-page"
29- TriggerScriptWindowOpen TriggerKind = "script-window-open"
30- TriggerNamedTargetNavigation TriggerKind = "named-target-navigation"
31- TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
32-)
33-
34-// RequestContextDisposition makes request-context inheritance explicit at the
35-// policy seam without leaking infrastructure request-context types upward.
36-type RequestContextDisposition string
37-
38-const (
39- // RequestContextInheritParent preserves the parent request context/session.
40- RequestContextInheritParent RequestContextDisposition = "inherit-parent"
41- // RequestContextIsolate starts from a fresh unrelated request context.
42- RequestContextIsolate RequestContextDisposition = "isolate"
43-)
44-
45-// NewBrowsingContextRequest is the normalized request captured from engine/UI
46-// signals before policy decides how it should be hosted.
47-type NewBrowsingContextRequest struct {
48- ParentWebViewID WebViewID
49-
50- SourceBrowserID int32
51- SourceFrameID string
52- SourceFrameURL string
53-
54- TargetURI string
55- TargetFrameName string
56- TargetDisposition WindowDisposition
57-
58- IsUserGesture bool
59- NoJavaScriptAccess bool
60- WindowFeatures string
61-
62- TriggerKind TriggerKind
63- AuthIntent bool
64- RequiresNativeOpener bool
65- RequestContextDisposition RequestContextDisposition
66-}
67-
68-// HostDecisionKind categorizes the approved host choices for a new browsing context.
69-type HostDecisionKind string
70-
71-const (
72- HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
73- HostDecisionCreatePane HostDecisionKind = "create-pane"
74- HostDecisionCreateNativeWin HostDecisionKind = "create-native-window"
75- HostDecisionDeny HostDecisionKind = "deny"
76-)
77-
78-// HostDecision represents the output of browsing-context policy.
79-type HostDecision struct {
80- Kind HostDecisionKind
81-
82- ReuseContextName string
83- BrowsingContextName string
84-
85- RequestContextDisposition RequestContextDisposition
86- RequiresNativeOpener bool
87- Reason string
88-}
89 6
90 // PaneHostedBrowsingContextCapable is implemented by engines that need an7 // PaneHostedBrowsingContextCapable is implemented by engines that need an
91 // explicit transition from a related/native-popup shell into a pane-hosted8 // explicit transition from a related/native-popup shell into a pane-hosted
@@ -98,8 +15,8 @@ type PaneHostedBrowsingContextCapable interface {
98 // host decision to an engine webview so lower-level popup handlers can honor15 // host decision to an engine webview so lower-level popup handlers can honor
99 // pane-vs-native behavior synchronously.16 // pane-vs-native behavior synchronously.
100 type BrowsingContextHostDecisionCapable interface {17 type BrowsingContextHostDecisionCapable interface {
101- SetBrowsingContextHostDecision(decision HostDecision)18+ SetBrowsingContextHostDecision(decision dto.HostDecision)
102- BrowsingContextHostDecision() (HostDecision, bool)19+ BrowsingContextHostDecision() (dto.HostDecision, bool)
103 }20 }
104 21
105 // NativePopupHostAbortCapable lets the engine-level popup seam abort an22 // NativePopupHostAbortCapable lets the engine-level popup seam abort an
M internal/application/port/webview.go +6 -2
@@ -3,7 +3,11 @@
33 // remain independent of specific implementations (WebKit, GTK, etc.).
44 package port
55
6-import "context"
6+import (
7+ "context"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+)
711
812 // WebViewID uniquely identifies a WebView instance.
913 type WebViewID uint64
@@ -72,7 +76,7 @@ type PopupRequest struct {
7276 SourceFrameID string
7377 SourceFrameURL string
7478
75- TargetDisposition WindowDisposition
79+ TargetDisposition dto.WindowDisposition
7680 WindowFeatures string
7781 }
7882
@@ -3,7 +3,11 @@
3 // remain independent of specific implementations (WebKit, GTK, etc.).3 // remain independent of specific implementations (WebKit, GTK, etc.).
4 package port4 package port
5 5
6-import "context"6+import (
7+ "context"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+)
7 11
8 // WebViewID uniquely identifies a WebView instance.12 // WebViewID uniquely identifies a WebView instance.
9 type WebViewID uint6413 type WebViewID uint64
@@ -72,7 +76,7 @@ type PopupRequest struct {
72 SourceFrameID string76 SourceFrameID string
73 SourceFrameURL string77 SourceFrameURL string
74 78
75- TargetDisposition WindowDisposition79+ TargetDisposition dto.WindowDisposition
76 WindowFeatures string80 WindowFeatures string
77 }81 }
78 82
M internal/infrastructure/cef/webview.go +5 -4
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/puregotk/v4/glib"
1919 "github.com/bnema/puregotk/v4/gtk"
2020
21+ "github.com/bnema/dumber/internal/application/dto"
2122 "github.com/bnema/dumber/internal/application/port"
2223 "github.com/bnema/dumber/internal/logging"
2324 )
@@ -120,7 +121,7 @@ type WebView struct {
120121 // Callbacks and browsing-context state set by the UI layer.
121122 mu sync.RWMutex
122123 callbacks *port.WebViewCallbacks
123- browsingContextDecision port.HostDecision
124+ browsingContextDecision dto.HostDecision
124125 hasBrowsingContextDecision bool
125126 nativePopupHostAbort func()
126127
@@ -677,7 +678,7 @@ func (wv *WebView) PreparePaneHostedBrowsingContext() {
677678 wv.discardNativePopupCandidate()
678679 }
679680
680-func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {
681+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
681682 if wv == nil {
682683 return
683684 }
@@ -687,9 +688,9 @@ func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {
687688 wv.mu.Unlock()
688689 }
689690
690-func (wv *WebView) BrowsingContextHostDecision() (port.HostDecision, bool) {
691+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
691692 if wv == nil {
692- return port.HostDecision{}, false
693+ return dto.HostDecision{}, false
693694 }
694695 wv.mu.RLock()
695696 defer wv.mu.RUnlock()
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/puregotk/v4/glib"18 "github.com/bnema/puregotk/v4/glib"
19 "github.com/bnema/puregotk/v4/gtk"19 "github.com/bnema/puregotk/v4/gtk"
20 20
21+ "github.com/bnema/dumber/internal/application/dto"
21 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
22 "github.com/bnema/dumber/internal/logging"23 "github.com/bnema/dumber/internal/logging"
23 )24 )
@@ -120,7 +121,7 @@ type WebView struct {
120 // Callbacks and browsing-context state set by the UI layer.121 // Callbacks and browsing-context state set by the UI layer.
121 mu sync.RWMutex122 mu sync.RWMutex
122 callbacks *port.WebViewCallbacks123 callbacks *port.WebViewCallbacks
123- browsingContextDecision port.HostDecision124+ browsingContextDecision dto.HostDecision
124 hasBrowsingContextDecision bool125 hasBrowsingContextDecision bool
125 nativePopupHostAbort func()126 nativePopupHostAbort func()
126 127
@@ -677,7 +678,7 @@ func (wv *WebView) PreparePaneHostedBrowsingContext() {
677 wv.discardNativePopupCandidate()678 wv.discardNativePopupCandidate()
678 }679 }
679 680
680-func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {681+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
681 if wv == nil {682 if wv == nil {
682 return683 return
683 }684 }
@@ -687,9 +688,9 @@ func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {
687 wv.mu.Unlock()688 wv.mu.Unlock()
688 }689 }
689 690
690-func (wv *WebView) BrowsingContextHostDecision() (port.HostDecision, bool) {691+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
691 if wv == nil {692 if wv == nil {
692- return port.HostDecision{}, false693+ return dto.HostDecision{}, false
693 }694 }
694 wv.mu.RLock()695 wv.mu.RLock()
695 defer wv.mu.RUnlock()696 defer wv.mu.RUnlock()
M internal/infrastructure/cef/webview_handlers.go +9 -8
@@ -8,6 +8,7 @@ import (
88 purecef "github.com/bnema/purego-cef/cef"
99 cef2gtk "github.com/bnema/purego-cef2gtk"
1010
11+ "github.com/bnema/dumber/internal/application/dto"
1112 "github.com/bnema/dumber/internal/application/port"
1213 downloadutil "github.com/bnema/dumber/internal/domain/download"
1314 "github.com/bnema/dumber/internal/logging"
@@ -419,19 +420,19 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
419420 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
420421 }
421422
422-func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) port.WindowDisposition {
423+func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
423424 switch disposition {
424425 case purecef.WindowOpenDispositionWodCurrentTab:
425- return port.WindowDispositionCurrentTab
426+ return dto.WindowDispositionCurrentTab
426427 case purecef.WindowOpenDispositionWodNewPopup:
427- return port.WindowDispositionNewPopup
428+ return dto.WindowDispositionNewPopup
428429 case purecef.WindowOpenDispositionWodNewWindow:
429- return port.WindowDispositionNewWindow
430+ return dto.WindowDispositionNewWindow
430431 case purecef.WindowOpenDispositionWodNewForegroundTab,
431432 purecef.WindowOpenDispositionWodNewBackgroundTab,
432433 purecef.WindowOpenDispositionWodSingletonTab,
433434 purecef.WindowOpenDispositionWodSwitchToTab:
434- return port.WindowDispositionNewTab
435+ return dto.WindowDispositionNewTab
435436 default:
436437 return ""
437438 }
@@ -498,7 +499,7 @@ func (h *handlerSet) OnBeforePopup(
498499 return true
499500 }
500501
501- decision := port.HostDecision{}
502+ decision := dto.HostDecision{}
502503 if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
503504 if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
504505 decision = got
@@ -507,7 +508,7 @@ func (h *handlerSet) OnBeforePopup(
507508
508509 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
509510 switch decision.Kind {
510- case port.HostDecisionCreateNativeWin:
511+ case dto.HostDecisionCreateNativeWin:
511512 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
512513 logging.FromContext(h.currentContext()).Debug().
513514 Int32("popup_id", popupID).
@@ -524,7 +525,7 @@ func (h *handlerSet) OnBeforePopup(
524525 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
525526 Msg("cef: native popup arming failed, denying without fallback")
526527 return true
527- case port.HostDecisionCreatePane, port.HostDecisionReuseNamedPane:
528+ case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane:
528529 logging.FromContext(h.currentContext()).Debug().
529530 Int32("popup_id", popupID).
530531 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
@@ -8,6 +8,7 @@ import (
8 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
9 cef2gtk "github.com/bnema/purego-cef2gtk"9 cef2gtk "github.com/bnema/purego-cef2gtk"
10 10
11+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
12 downloadutil "github.com/bnema/dumber/internal/domain/download"13 downloadutil "github.com/bnema/dumber/internal/domain/download"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
@@ -419,19 +420,19 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
419 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {420 func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
420 }421 }
421 422
422-func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) port.WindowDisposition {423+func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
423 switch disposition {424 switch disposition {
424 case purecef.WindowOpenDispositionWodCurrentTab:425 case purecef.WindowOpenDispositionWodCurrentTab:
425- return port.WindowDispositionCurrentTab426+ return dto.WindowDispositionCurrentTab
426 case purecef.WindowOpenDispositionWodNewPopup:427 case purecef.WindowOpenDispositionWodNewPopup:
427- return port.WindowDispositionNewPopup428+ return dto.WindowDispositionNewPopup
428 case purecef.WindowOpenDispositionWodNewWindow:429 case purecef.WindowOpenDispositionWodNewWindow:
429- return port.WindowDispositionNewWindow430+ return dto.WindowDispositionNewWindow
430 case purecef.WindowOpenDispositionWodNewForegroundTab,431 case purecef.WindowOpenDispositionWodNewForegroundTab,
431 purecef.WindowOpenDispositionWodNewBackgroundTab,432 purecef.WindowOpenDispositionWodNewBackgroundTab,
432 purecef.WindowOpenDispositionWodSingletonTab,433 purecef.WindowOpenDispositionWodSingletonTab,
433 purecef.WindowOpenDispositionWodSwitchToTab:434 purecef.WindowOpenDispositionWodSwitchToTab:
434- return port.WindowDispositionNewTab435+ return dto.WindowDispositionNewTab
435 default:436 default:
436 return ""437 return ""
437 }438 }
@@ -498,7 +499,7 @@ func (h *handlerSet) OnBeforePopup(
498 return true499 return true
499 }500 }
500 501
501- decision := port.HostDecision{}502+ decision := dto.HostDecision{}
502 if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {503 if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
503 if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {504 if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
504 decision = got505 decision = got
@@ -507,7 +508,7 @@ func (h *handlerSet) OnBeforePopup(
507 508
508 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)509 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
509 switch decision.Kind {510 switch decision.Kind {
510- case port.HostDecisionCreateNativeWin:511+ case dto.HostDecisionCreateNativeWin:
511 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {512 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
512 logging.FromContext(h.currentContext()).Debug().513 logging.FromContext(h.currentContext()).Debug().
513 Int32("popup_id", popupID).514 Int32("popup_id", popupID).
@@ -524,7 +525,7 @@ func (h *handlerSet) OnBeforePopup(
524 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).525 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
525 Msg("cef: native popup arming failed, denying without fallback")526 Msg("cef: native popup arming failed, denying without fallback")
526 return true527 return true
527- case port.HostDecisionCreatePane, port.HostDecisionReuseNamedPane:528+ case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane:
528 logging.FromContext(h.currentContext()).Debug().529 logging.FromContext(h.currentContext()).Debug().
529 Int32("popup_id", popupID).530 Int32("popup_id", popupID).
530 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).531 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
M internal/infrastructure/cef/webview_handlers_test.go +2 -2
@@ -250,7 +250,7 @@ func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T
250250 func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {
251251 parentWV := &WebView{ctx: context.Background(), id: 41}
252252 popupWV := &WebView{ctx: context.Background(), id: 42}
253- popupWV.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreatePane})
253+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
254254 parentWV.SetCallbacks(&port.WebViewCallbacks{
255255 OnCreate: func(req port.PopupRequest) port.WebView {
256256 popupWV.PreparePaneHostedBrowsingContext()
@@ -270,7 +270,7 @@ func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) {
270270 parentWV := &WebView{ctx: context.Background(), id: 51}
271271 popupWV := &WebView{ctx: context.Background(), id: 52}
272272 popupWV.markNativePopupCandidate(parentWV)
273- popupWV.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreateNativeWin})
273+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
274274 aborted := false
275275 popupWV.SetNativePopupHostAbort(func() { aborted = true })
276276 parentWV.SetCallbacks(&port.WebViewCallbacks{
@@ -250,7 +250,7 @@ func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T
250 func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {250 func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {
251 parentWV := &WebView{ctx: context.Background(), id: 41}251 parentWV := &WebView{ctx: context.Background(), id: 41}
252 popupWV := &WebView{ctx: context.Background(), id: 42}252 popupWV := &WebView{ctx: context.Background(), id: 42}
253- popupWV.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreatePane})253+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
254 parentWV.SetCallbacks(&port.WebViewCallbacks{254 parentWV.SetCallbacks(&port.WebViewCallbacks{
255 OnCreate: func(req port.PopupRequest) port.WebView {255 OnCreate: func(req port.PopupRequest) port.WebView {
256 popupWV.PreparePaneHostedBrowsingContext()256 popupWV.PreparePaneHostedBrowsingContext()
@@ -270,7 +270,7 @@ func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) {
270 parentWV := &WebView{ctx: context.Background(), id: 51}270 parentWV := &WebView{ctx: context.Background(), id: 51}
271 popupWV := &WebView{ctx: context.Background(), id: 52}271 popupWV := &WebView{ctx: context.Background(), id: 52}
272 popupWV.markNativePopupCandidate(parentWV)272 popupWV.markNativePopupCandidate(parentWV)
273- popupWV.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreateNativeWin})273+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
274 aborted := false274 aborted := false
275 popupWV.SetNativePopupHostAbort(func() { aborted = true })275 popupWV.SetNativePopupHostAbort(func() { aborted = true })
276 parentWV.SetCallbacks(&port.WebViewCallbacks{276 parentWV.SetCallbacks(&port.WebViewCallbacks{
M internal/infrastructure/webkit/webview.go +6 -5
@@ -10,6 +10,7 @@ import (
1010 "sync/atomic"
1111 "time"
1212
13+ "github.com/bnema/dumber/internal/application/dto"
1314 "github.com/bnema/dumber/internal/application/port"
1415 downloadutil "github.com/bnema/dumber/internal/domain/download"
1516 "github.com/bnema/dumber/internal/domain/entity"
@@ -161,7 +162,7 @@ type WebView struct {
161162 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
162163 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
163164 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
164- browsingContextDecision port.HostDecision
165+ browsingContextDecision dto.HostDecision
165166 hasBrowsingContextDecision bool
166167 nativePopupHostAbort func()
167168
@@ -521,14 +522,14 @@ func (wv *WebView) connectCloseSignal() {
521522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
522523 }
523524
524-func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {
525+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
525526 wv.mu.Lock()
526527 wv.browsingContextDecision = decision
527528 wv.hasBrowsingContextDecision = true
528529 wv.mu.Unlock()
529530 }
530531
531-func (wv *WebView) BrowsingContextHostDecision() (port.HostDecision, bool) {
532+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
532533 wv.mu.RLock()
533534 defer wv.mu.RUnlock()
534535 return wv.browsingContextDecision, wv.hasBrowsingContextDecision
@@ -1927,7 +1928,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
19271928 // 3. Clear async callback references and popup-hosting state
19281929 wv.mu.Lock()
19291930 wv.asyncCallbacks = nil
1930- wv.browsingContextDecision = port.HostDecision{}
1931+ wv.browsingContextDecision = dto.HostDecision{}
19311932 wv.hasBrowsingContextDecision = false
19321933 wv.nativePopupHostAbort = nil
19331934 wv.mu.Unlock()
@@ -1997,7 +1998,7 @@ func (wv *WebView) ResetForPoolReuse() {
19971998 wv.isLoading = false
19981999 wv.asyncCallbacks = nil
19992000 wv.runJSErrorStats = make(map[string]runJSErrorStat)
2000- wv.browsingContextDecision = port.HostDecision{}
2001+ wv.browsingContextDecision = dto.HostDecision{}
20012002 wv.hasBrowsingContextDecision = false
20022003 wv.nativePopupHostAbort = nil
20032004 wv.lastProgressUpdate.Store(0)
@@ -10,6 +10,7 @@ import (
10 "sync/atomic"10 "sync/atomic"
11 "time"11 "time"
12 12
13+ "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
14 downloadutil "github.com/bnema/dumber/internal/domain/download"15 downloadutil "github.com/bnema/dumber/internal/domain/download"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
@@ -161,7 +162,7 @@ type WebView struct {
161 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops162 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
162 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)163 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
163 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)164 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
164- browsingContextDecision port.HostDecision165+ browsingContextDecision dto.HostDecision
165 hasBrowsingContextDecision bool166 hasBrowsingContextDecision bool
166 nativePopupHostAbort func()167 nativePopupHostAbort func()
167 168
@@ -521,14 +522,14 @@ func (wv *WebView) connectCloseSignal() {
521 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
522 }523 }
523 524
524-func (wv *WebView) SetBrowsingContextHostDecision(decision port.HostDecision) {525+func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
525 wv.mu.Lock()526 wv.mu.Lock()
526 wv.browsingContextDecision = decision527 wv.browsingContextDecision = decision
527 wv.hasBrowsingContextDecision = true528 wv.hasBrowsingContextDecision = true
528 wv.mu.Unlock()529 wv.mu.Unlock()
529 }530 }
530 531
531-func (wv *WebView) BrowsingContextHostDecision() (port.HostDecision, bool) {532+func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
532 wv.mu.RLock()533 wv.mu.RLock()
533 defer wv.mu.RUnlock()534 defer wv.mu.RUnlock()
534 return wv.browsingContextDecision, wv.hasBrowsingContextDecision535 return wv.browsingContextDecision, wv.hasBrowsingContextDecision
@@ -1927,7 +1928,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1927 // 3. Clear async callback references and popup-hosting state1928 // 3. Clear async callback references and popup-hosting state
1928 wv.mu.Lock()1929 wv.mu.Lock()
1929 wv.asyncCallbacks = nil1930 wv.asyncCallbacks = nil
1930- wv.browsingContextDecision = port.HostDecision{}1931+ wv.browsingContextDecision = dto.HostDecision{}
1931 wv.hasBrowsingContextDecision = false1932 wv.hasBrowsingContextDecision = false
1932 wv.nativePopupHostAbort = nil1933 wv.nativePopupHostAbort = nil
1933 wv.mu.Unlock()1934 wv.mu.Unlock()
@@ -1997,7 +1998,7 @@ func (wv *WebView) ResetForPoolReuse() {
1997 wv.isLoading = false1998 wv.isLoading = false
1998 wv.asyncCallbacks = nil1999 wv.asyncCallbacks = nil
1999 wv.runJSErrorStats = make(map[string]runJSErrorStat)2000 wv.runJSErrorStats = make(map[string]runJSErrorStat)
2000- wv.browsingContextDecision = port.HostDecision{}2001+ wv.browsingContextDecision = dto.HostDecision{}
2001 wv.hasBrowsingContextDecision = false2002 wv.hasBrowsingContextDecision = false
2002 wv.nativePopupHostAbort = nil2003 wv.nativePopupHostAbort = nil
2003 wv.lastProgressUpdate.Store(0)2004 wv.lastProgressUpdate.Store(0)
M internal/infrastructure/webkit/webview_reuse_state_test.go +5 -5
@@ -3,7 +3,7 @@ package webkit
33 import (
44 "testing"
55
6- "github.com/bnema/dumber/internal/application/port"
6+ "github.com/bnema/dumber/internal/application/dto"
77 "github.com/stretchr/testify/assert"
88 )
99
@@ -11,14 +11,14 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) {
1111 wv := &WebView{}
1212 abortCalled := false
1313
14- wv.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreateNativeWin})
14+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
1515 wv.SetNativePopupHostAbort(func() { abortCalled = true })
1616
1717 wv.ResetForPoolReuse()
1818
1919 decision, hasDecision := wv.BrowsingContextHostDecision()
2020 assert.False(t, hasDecision)
21- assert.Equal(t, port.HostDecision{}, decision)
21+ assert.Equal(t, dto.HostDecision{}, decision)
2222
2323 wv.AbortNativePopupHost()
2424 assert.False(t, abortCalled)
@@ -28,14 +28,14 @@ func TestDestroyWithPolicy_ClearsBrowsingContextHostState(t *testing.T) {
2828 wv := &WebView{}
2929 abortCalled := false
3030
31- wv.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreatePane})
31+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
3232 wv.SetNativePopupHostAbort(func() { abortCalled = true })
3333
3434 wv.DestroyWithPolicy(terminatePolicyNever)
3535
3636 decision, hasDecision := wv.BrowsingContextHostDecision()
3737 assert.False(t, hasDecision)
38- assert.Equal(t, port.HostDecision{}, decision)
38+ assert.Equal(t, dto.HostDecision{}, decision)
3939
4040 wv.AbortNativePopupHost()
4141 assert.False(t, abortCalled)
@@ -3,7 +3,7 @@ package webkit
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/dumber/internal/application/port"6+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8 )8 )
9 9
@@ -11,14 +11,14 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) {
11 wv := &WebView{}11 wv := &WebView{}
12 abortCalled := false12 abortCalled := false
13 13
14- wv.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreateNativeWin})14+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
15 wv.SetNativePopupHostAbort(func() { abortCalled = true })15 wv.SetNativePopupHostAbort(func() { abortCalled = true })
16 16
17 wv.ResetForPoolReuse()17 wv.ResetForPoolReuse()
18 18
19 decision, hasDecision := wv.BrowsingContextHostDecision()19 decision, hasDecision := wv.BrowsingContextHostDecision()
20 assert.False(t, hasDecision)20 assert.False(t, hasDecision)
21- assert.Equal(t, port.HostDecision{}, decision)21+ assert.Equal(t, dto.HostDecision{}, decision)
22 22
23 wv.AbortNativePopupHost()23 wv.AbortNativePopupHost()
24 assert.False(t, abortCalled)24 assert.False(t, abortCalled)
@@ -28,14 +28,14 @@ func TestDestroyWithPolicy_ClearsBrowsingContextHostState(t *testing.T) {
28 wv := &WebView{}28 wv := &WebView{}
29 abortCalled := false29 abortCalled := false
30 30
31- wv.SetBrowsingContextHostDecision(port.HostDecision{Kind: port.HostDecisionCreatePane})31+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
32 wv.SetNativePopupHostAbort(func() { abortCalled = true })32 wv.SetNativePopupHostAbort(func() { abortCalled = true })
33 33
34 wv.DestroyWithPolicy(terminatePolicyNever)34 wv.DestroyWithPolicy(terminatePolicyNever)
35 35
36 decision, hasDecision := wv.BrowsingContextHostDecision()36 decision, hasDecision := wv.BrowsingContextHostDecision()
37 assert.False(t, hasDecision)37 assert.False(t, hasDecision)
38- assert.Equal(t, port.HostDecision{}, decision)38+ assert.Equal(t, dto.HostDecision{}, decision)
39 39
40 wv.AbortNativePopupHost()40 wv.AbortNativePopupHost()
41 assert.False(t, abortCalled)41 assert.False(t, abortCalled)
M internal/ui/coordinator/content/auth_window_observer.go +18 -8
@@ -2,6 +2,7 @@ package content
22
33 import (
44 "context"
5+ "sync"
56 "time"
67
78 "github.com/bnema/dumber/internal/application/port"
@@ -11,6 +12,16 @@ import (
1112
1213 const nativePopupOAuthCloseDelay = 500 * time.Millisecond
1314
15+var scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
16+ time.AfterFunc(delay, func() {
17+ cb := glib.SourceFunc(func(_ uintptr) bool {
18+ fn()
19+ return false
20+ })
21+ glib.IdleAdd(&cb, 0)
22+ })
23+}
24+
1425 func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePopupInput) {
1526 if c == nil || input.PopupWebView == nil || !IsOAuthURL(input.TargetURI) {
1627 return
@@ -27,19 +38,18 @@ func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePo
2738 }
2839 c.trackOAuthPopup(popupID, input.ParentPaneID, input.ParentURIAtOpen)
2940
41+ var requestCloseOnce sync.Once
3042 requestClose := func(reason string) {
31- logging.FromContext(ctx).Info().
32- Uint64("popup_id", uint64(popupID)).
33- Str("reason", reason).
34- Msg("native popup oauth callback detected, closing popup")
35- time.AfterFunc(nativePopupOAuthCloseDelay, func() {
36- cb := glib.SourceFunc(func(_ uintptr) bool {
43+ requestCloseOnce.Do(func() {
44+ logging.FromContext(ctx).Info().
45+ Uint64("popup_id", uint64(popupID)).
46+ Str("reason", reason).
47+ Msg("native popup oauth callback detected, closing popup")
48+ scheduleNativePopupOAuthClose(nativePopupOAuthCloseDelay, func() {
3749 if input.PopupWebView != nil && !input.PopupWebView.IsDestroyed() {
3850 oauthWV.Close()
3951 }
40- return false
4152 })
42- glib.IdleAdd(&cb, 0)
4353 })
4454 }
4555
@@ -2,6 +2,7 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
5 "time"6 "time"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
@@ -11,6 +12,16 @@ import (
11 12
12 const nativePopupOAuthCloseDelay = 500 * time.Millisecond13 const nativePopupOAuthCloseDelay = 500 * time.Millisecond
13 14
15+var scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
16+ time.AfterFunc(delay, func() {
17+ cb := glib.SourceFunc(func(_ uintptr) bool {
18+ fn()
19+ return false
20+ })
21+ glib.IdleAdd(&cb, 0)
22+ })
23+}
24+
14 func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePopupInput) {25 func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePopupInput) {
15 if c == nil || input.PopupWebView == nil || !IsOAuthURL(input.TargetURI) {26 if c == nil || input.PopupWebView == nil || !IsOAuthURL(input.TargetURI) {
16 return27 return
@@ -27,19 +38,18 @@ func (c *Coordinator) ObserveNativePopupAuth(ctx context.Context, input NativePo
27 }38 }
28 c.trackOAuthPopup(popupID, input.ParentPaneID, input.ParentURIAtOpen)39 c.trackOAuthPopup(popupID, input.ParentPaneID, input.ParentURIAtOpen)
29 40
41+ var requestCloseOnce sync.Once
30 requestClose := func(reason string) {42 requestClose := func(reason string) {
31- logging.FromContext(ctx).Info().43+ requestCloseOnce.Do(func() {
32- Uint64("popup_id", uint64(popupID)).44+ logging.FromContext(ctx).Info().
33- Str("reason", reason).45+ Uint64("popup_id", uint64(popupID)).
34- Msg("native popup oauth callback detected, closing popup")46+ Str("reason", reason).
35- time.AfterFunc(nativePopupOAuthCloseDelay, func() {47+ Msg("native popup oauth callback detected, closing popup")
36- cb := glib.SourceFunc(func(_ uintptr) bool {48+ scheduleNativePopupOAuthClose(nativePopupOAuthCloseDelay, func() {
37 if input.PopupWebView != nil && !input.PopupWebView.IsDestroyed() {49 if input.PopupWebView != nil && !input.PopupWebView.IsDestroyed() {
38 oauthWV.Close()50 oauthWV.Close()
39 }51 }
40- return false
41 })52 })
42- glib.IdleAdd(&cb, 0)
43 })53 })
44 }54 }
45 55
M internal/ui/coordinator/content/browsing_context_policy.go +37 -36
@@ -3,60 +3,61 @@ package content
33 import (
44 "strings"
55
6+ "github.com/bnema/dumber/internal/application/dto"
67 "github.com/bnema/dumber/internal/application/port"
78 )
89
910 type browsingContextPolicy struct{}
1011
11-func (browsingContextPolicy) Decide(req port.NewBrowsingContextRequest, namedContextExists bool) port.HostDecision {
12- decision := port.HostDecision{
12+func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
13+ decision := dto.HostDecision{
1314 RequestContextDisposition: req.RequestContextDisposition,
1415 RequiresNativeOpener: req.RequiresNativeOpener,
1516 }
1617 if decision.RequestContextDisposition == "" {
17- decision.RequestContextDisposition = port.RequestContextInheritParent
18+ decision.RequestContextDisposition = dto.RequestContextInheritParent
1819 }
1920
2021 if strings.TrimSpace(req.TargetURI) == "" {
21- decision.Kind = port.HostDecisionDeny
22+ decision.Kind = dto.HostDecisionDeny
2223 decision.Reason = "empty target URI"
2324 return decision
2425 }
2526
2627 if req.AuthIntent {
27- decision.Kind = port.HostDecisionCreateNativeWin
28+ decision.Kind = dto.HostDecisionCreateNativeWin
2829 decision.Reason = "auth intent requires native window"
2930 return decision
3031 }
3132 if req.RequiresNativeOpener {
32- decision.Kind = port.HostDecisionCreateNativeWin
33+ decision.Kind = dto.HostDecisionCreateNativeWin
3334 decision.Reason = "native opener required"
3435 return decision
3536 }
3637 if isAmbiguousNativeBrowsingContext(req) {
37- decision.Kind = port.HostDecisionCreateNativeWin
38+ decision.Kind = dto.HostDecisionCreateNativeWin
3839 decision.Reason = "ambiguous opener-coupled request prefers native window"
3940 return decision
4041 }
4142
4243 name := reusableBrowsingContextName(req.TargetFrameName)
4344 if namedContextExists && name != "" {
44- decision.Kind = port.HostDecisionReuseNamedPane
45+ decision.Kind = dto.HostDecisionReuseNamedPane
4546 decision.ReuseContextName = name
4647 decision.BrowsingContextName = name
4748 decision.Reason = "reuse named browsing context in current window"
4849 return decision
4950 }
5051
51- decision.Kind = port.HostDecisionCreatePane
52+ decision.Kind = dto.HostDecisionCreatePane
5253 decision.BrowsingContextName = name
5354 decision.Reason = "pane-hosted browsing context"
5455 return decision
5556 }
5657
57-func buildPopupBrowsingContextRequest(req port.PopupRequest) port.NewBrowsingContextRequest {
58- return port.NewBrowsingContextRequest{
59- ParentWebViewID: req.ParentViewID,
58+func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest {
59+ return dto.NewBrowsingContextRequest{
60+ ParentWebViewID: uint64(req.ParentViewID),
6061 SourceBrowserID: req.SourceBrowserID,
6162 SourceFrameID: req.SourceFrameID,
6263 SourceFrameURL: req.SourceFrameURL,
@@ -69,61 +70,61 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) port.NewBrowsingCon
6970 TriggerKind: inferPopupTriggerKind(req),
7071 AuthIntent: IsOAuthURL(req.TargetURI),
7172 RequiresNativeOpener: false,
72- RequestContextDisposition: port.RequestContextInheritParent,
73+ RequestContextDisposition: dto.RequestContextInheritParent,
7374 }
7475 }
7576
76-func buildLinkBrowsingContextRequest(parentWebViewID port.WebViewID, uri string) port.NewBrowsingContextRequest {
77- return port.NewBrowsingContextRequest{
78- ParentWebViewID: parentWebViewID,
77+func buildLinkBrowsingContextRequest(parentWebViewID port.WebViewID, uri string) dto.NewBrowsingContextRequest {
78+ return dto.NewBrowsingContextRequest{
79+ ParentWebViewID: uint64(parentWebViewID),
7980 TargetURI: uri,
8081 TargetFrameName: "_blank",
81- TargetDisposition: port.WindowDispositionNewTab,
82+ TargetDisposition: dto.WindowDispositionNewTab,
8283 IsUserGesture: true,
8384 NoJavaScriptAccess: true,
84- TriggerKind: port.TriggerLinkNewPage,
85+ TriggerKind: dto.TriggerLinkNewPage,
8586 AuthIntent: IsOAuthURL(uri),
86- RequestContextDisposition: port.RequestContextInheritParent,
87+ RequestContextDisposition: dto.RequestContextInheritParent,
8788 }
8889 }
8990
90-func inferPopupWindowDisposition(req port.PopupRequest) port.WindowDisposition {
91+func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
9192 if req.TargetDisposition != "" {
9293 return req.TargetDisposition
9394 }
9495 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
95- return port.WindowDispositionNewTab
96+ return dto.WindowDispositionNewTab
9697 }
97- return port.WindowDispositionNewPopup
98+ return dto.WindowDispositionNewPopup
9899 }
99100
100-func inferPopupTriggerKind(req port.PopupRequest) port.TriggerKind {
101+func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
101102 switch inferPopupWindowDisposition(req) {
102- case port.WindowDispositionCurrentTab:
103+ case dto.WindowDispositionCurrentTab:
103104 if reusableBrowsingContextName(req.FrameName) != "" {
104- return port.TriggerNamedTargetNavigation
105+ return dto.TriggerNamedTargetNavigation
105106 }
106- return port.TriggerLinkNewPage
107- case port.WindowDispositionNewTab:
107+ return dto.TriggerLinkNewPage
108+ case dto.WindowDispositionNewTab:
108109 if reusableBrowsingContextName(req.FrameName) != "" {
109- return port.TriggerNamedTargetNavigation
110+ return dto.TriggerNamedTargetNavigation
110111 }
111- return port.TriggerLinkNewPage
112- case port.WindowDispositionNewPopup, port.WindowDispositionNewWindow:
112+ return dto.TriggerLinkNewPage
113+ case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
113114 if reusableBrowsingContextName(req.FrameName) != "" {
114- return port.TriggerNamedTargetNavigation
115+ return dto.TriggerNamedTargetNavigation
115116 }
116- return port.TriggerScriptWindowOpen
117+ return dto.TriggerScriptWindowOpen
117118 default:
118119 if reusableBrowsingContextName(req.FrameName) != "" {
119- return port.TriggerNamedTargetNavigation
120+ return dto.TriggerNamedTargetNavigation
120121 }
121- return port.TriggerUnknown
122+ return dto.TriggerUnknown
122123 }
123124 }
124125
125-func isAmbiguousNativeBrowsingContext(req port.NewBrowsingContextRequest) bool {
126- return req.TriggerKind == port.TriggerUnknown && !req.NoJavaScriptAccess
126+func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool {
127+ return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess
127128 }
128129
129130 func reusableBrowsingContextName(frameName string) string {
@@ -3,60 +3,61 @@ package content
3 import (3 import (
4 "strings"4 "strings"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 )8 )
8 9
9 type browsingContextPolicy struct{}10 type browsingContextPolicy struct{}
10 11
11-func (browsingContextPolicy) Decide(req port.NewBrowsingContextRequest, namedContextExists bool) port.HostDecision {12+func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
12- decision := port.HostDecision{13+ decision := dto.HostDecision{
13 RequestContextDisposition: req.RequestContextDisposition,14 RequestContextDisposition: req.RequestContextDisposition,
14 RequiresNativeOpener: req.RequiresNativeOpener,15 RequiresNativeOpener: req.RequiresNativeOpener,
15 }16 }
16 if decision.RequestContextDisposition == "" {17 if decision.RequestContextDisposition == "" {
17- decision.RequestContextDisposition = port.RequestContextInheritParent18+ decision.RequestContextDisposition = dto.RequestContextInheritParent
18 }19 }
19 20
20 if strings.TrimSpace(req.TargetURI) == "" {21 if strings.TrimSpace(req.TargetURI) == "" {
21- decision.Kind = port.HostDecisionDeny22+ decision.Kind = dto.HostDecisionDeny
22 decision.Reason = "empty target URI"23 decision.Reason = "empty target URI"
23 return decision24 return decision
24 }25 }
25 26
26 if req.AuthIntent {27 if req.AuthIntent {
27- decision.Kind = port.HostDecisionCreateNativeWin28+ decision.Kind = dto.HostDecisionCreateNativeWin
28 decision.Reason = "auth intent requires native window"29 decision.Reason = "auth intent requires native window"
29 return decision30 return decision
30 }31 }
31 if req.RequiresNativeOpener {32 if req.RequiresNativeOpener {
32- decision.Kind = port.HostDecisionCreateNativeWin33+ decision.Kind = dto.HostDecisionCreateNativeWin
33 decision.Reason = "native opener required"34 decision.Reason = "native opener required"
34 return decision35 return decision
35 }36 }
36 if isAmbiguousNativeBrowsingContext(req) {37 if isAmbiguousNativeBrowsingContext(req) {
37- decision.Kind = port.HostDecisionCreateNativeWin38+ decision.Kind = dto.HostDecisionCreateNativeWin
38 decision.Reason = "ambiguous opener-coupled request prefers native window"39 decision.Reason = "ambiguous opener-coupled request prefers native window"
39 return decision40 return decision
40 }41 }
41 42
42 name := reusableBrowsingContextName(req.TargetFrameName)43 name := reusableBrowsingContextName(req.TargetFrameName)
43 if namedContextExists && name != "" {44 if namedContextExists && name != "" {
44- decision.Kind = port.HostDecisionReuseNamedPane45+ decision.Kind = dto.HostDecisionReuseNamedPane
45 decision.ReuseContextName = name46 decision.ReuseContextName = name
46 decision.BrowsingContextName = name47 decision.BrowsingContextName = name
47 decision.Reason = "reuse named browsing context in current window"48 decision.Reason = "reuse named browsing context in current window"
48 return decision49 return decision
49 }50 }
50 51
51- decision.Kind = port.HostDecisionCreatePane52+ decision.Kind = dto.HostDecisionCreatePane
52 decision.BrowsingContextName = name53 decision.BrowsingContextName = name
53 decision.Reason = "pane-hosted browsing context"54 decision.Reason = "pane-hosted browsing context"
54 return decision55 return decision
55 }56 }
56 57
57-func buildPopupBrowsingContextRequest(req port.PopupRequest) port.NewBrowsingContextRequest {58+func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest {
58- return port.NewBrowsingContextRequest{59+ return dto.NewBrowsingContextRequest{
59- ParentWebViewID: req.ParentViewID,60+ ParentWebViewID: uint64(req.ParentViewID),
60 SourceBrowserID: req.SourceBrowserID,61 SourceBrowserID: req.SourceBrowserID,
61 SourceFrameID: req.SourceFrameID,62 SourceFrameID: req.SourceFrameID,
62 SourceFrameURL: req.SourceFrameURL,63 SourceFrameURL: req.SourceFrameURL,
@@ -69,61 +70,61 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) port.NewBrowsingCon
69 TriggerKind: inferPopupTriggerKind(req),70 TriggerKind: inferPopupTriggerKind(req),
70 AuthIntent: IsOAuthURL(req.TargetURI),71 AuthIntent: IsOAuthURL(req.TargetURI),
71 RequiresNativeOpener: false,72 RequiresNativeOpener: false,
72- RequestContextDisposition: port.RequestContextInheritParent,73+ RequestContextDisposition: dto.RequestContextInheritParent,
73 }74 }
74 }75 }
75 76
76-func buildLinkBrowsingContextRequest(parentWebViewID port.WebViewID, uri string) port.NewBrowsingContextRequest {77+func buildLinkBrowsingContextRequest(parentWebViewID port.WebViewID, uri string) dto.NewBrowsingContextRequest {
77- return port.NewBrowsingContextRequest{78+ return dto.NewBrowsingContextRequest{
78- ParentWebViewID: parentWebViewID,79+ ParentWebViewID: uint64(parentWebViewID),
79 TargetURI: uri,80 TargetURI: uri,
80 TargetFrameName: "_blank",81 TargetFrameName: "_blank",
81- TargetDisposition: port.WindowDispositionNewTab,82+ TargetDisposition: dto.WindowDispositionNewTab,
82 IsUserGesture: true,83 IsUserGesture: true,
83 NoJavaScriptAccess: true,84 NoJavaScriptAccess: true,
84- TriggerKind: port.TriggerLinkNewPage,85+ TriggerKind: dto.TriggerLinkNewPage,
85 AuthIntent: IsOAuthURL(uri),86 AuthIntent: IsOAuthURL(uri),
86- RequestContextDisposition: port.RequestContextInheritParent,87+ RequestContextDisposition: dto.RequestContextInheritParent,
87 }88 }
88 }89 }
89 90
90-func inferPopupWindowDisposition(req port.PopupRequest) port.WindowDisposition {91+func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
91 if req.TargetDisposition != "" {92 if req.TargetDisposition != "" {
92 return req.TargetDisposition93 return req.TargetDisposition
93 }94 }
94 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {95 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
95- return port.WindowDispositionNewTab96+ return dto.WindowDispositionNewTab
96 }97 }
97- return port.WindowDispositionNewPopup98+ return dto.WindowDispositionNewPopup
98 }99 }
99 100
100-func inferPopupTriggerKind(req port.PopupRequest) port.TriggerKind {101+func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
101 switch inferPopupWindowDisposition(req) {102 switch inferPopupWindowDisposition(req) {
102- case port.WindowDispositionCurrentTab:103+ case dto.WindowDispositionCurrentTab:
103 if reusableBrowsingContextName(req.FrameName) != "" {104 if reusableBrowsingContextName(req.FrameName) != "" {
104- return port.TriggerNamedTargetNavigation105+ return dto.TriggerNamedTargetNavigation
105 }106 }
106- return port.TriggerLinkNewPage107+ return dto.TriggerLinkNewPage
107- case port.WindowDispositionNewTab:108+ case dto.WindowDispositionNewTab:
108 if reusableBrowsingContextName(req.FrameName) != "" {109 if reusableBrowsingContextName(req.FrameName) != "" {
109- return port.TriggerNamedTargetNavigation110+ return dto.TriggerNamedTargetNavigation
110 }111 }
111- return port.TriggerLinkNewPage112+ return dto.TriggerLinkNewPage
112- case port.WindowDispositionNewPopup, port.WindowDispositionNewWindow:113+ case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
113 if reusableBrowsingContextName(req.FrameName) != "" {114 if reusableBrowsingContextName(req.FrameName) != "" {
114- return port.TriggerNamedTargetNavigation115+ return dto.TriggerNamedTargetNavigation
115 }116 }
116- return port.TriggerScriptWindowOpen117+ return dto.TriggerScriptWindowOpen
117 default:118 default:
118 if reusableBrowsingContextName(req.FrameName) != "" {119 if reusableBrowsingContextName(req.FrameName) != "" {
119- return port.TriggerNamedTargetNavigation120+ return dto.TriggerNamedTargetNavigation
120 }121 }
121- return port.TriggerUnknown122+ return dto.TriggerUnknown
122 }123 }
123 }124 }
124 125
125-func isAmbiguousNativeBrowsingContext(req port.NewBrowsingContextRequest) bool {126+func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool {
126- return req.TriggerKind == port.TriggerUnknown && !req.NoJavaScriptAccess127+ return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess
127 }128 }
128 129
129 func reusableBrowsingContextName(frameName string) string {130 func reusableBrowsingContextName(frameName string) string {
M internal/ui/coordinator/content/browsing_context_policy_test.go +43 -42
@@ -3,6 +3,7 @@ package content
33 import (
44 "testing"
55
6+ "github.com/bnema/dumber/internal/application/dto"
67 "github.com/bnema/dumber/internal/application/port"
78 "github.com/stretchr/testify/assert"
89 )
@@ -13,76 +14,76 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
1314 policy := browsingContextPolicy{}
1415 tests := []struct {
1516 name string
16- request port.NewBrowsingContextRequest
17+ request dto.NewBrowsingContextRequest
1718 namedContextExist bool
18- wantKind port.HostDecisionKind
19+ wantKind dto.HostDecisionKind
1920 wantName string
2021 }{
2122 {
2223 name: "blank target stays pane-hosted",
23- request: port.NewBrowsingContextRequest{
24+ request: dto.NewBrowsingContextRequest{
2425 TargetURI: "https://example.com/docs",
2526 TargetFrameName: "_blank",
26- TriggerKind: port.TriggerLinkNewPage,
27- TargetDisposition: port.WindowDispositionNewTab,
28- RequestContextDisposition: port.RequestContextInheritParent,
27+ TriggerKind: dto.TriggerLinkNewPage,
28+ TargetDisposition: dto.WindowDispositionNewTab,
29+ RequestContextDisposition: dto.RequestContextInheritParent,
2930 },
30- wantKind: port.HostDecisionCreatePane,
31+ wantKind: dto.HostDecisionCreatePane,
3132 },
3233 {
3334 name: "named target reuses existing pane in same window",
34- request: port.NewBrowsingContextRequest{
35+ request: dto.NewBrowsingContextRequest{
3536 TargetURI: "https://example.com/second",
3637 TargetFrameName: "shared-pane",
37- TriggerKind: port.TriggerNamedTargetNavigation,
38- TargetDisposition: port.WindowDispositionNewPopup,
39- RequestContextDisposition: port.RequestContextInheritParent,
38+ TriggerKind: dto.TriggerNamedTargetNavigation,
39+ TargetDisposition: dto.WindowDispositionNewPopup,
40+ RequestContextDisposition: dto.RequestContextInheritParent,
4041 },
4142 namedContextExist: true,
42- wantKind: port.HostDecisionReuseNamedPane,
43+ wantKind: dto.HostDecisionReuseNamedPane,
4344 wantName: "shared-pane",
4445 },
4546 {
4647 name: "ordinary window.open stays pane-hosted",
47- request: port.NewBrowsingContextRequest{
48+ request: dto.NewBrowsingContextRequest{
4849 TargetURI: "https://example.com/help",
49- TriggerKind: port.TriggerScriptWindowOpen,
50- TargetDisposition: port.WindowDispositionNewPopup,
50+ TriggerKind: dto.TriggerScriptWindowOpen,
51+ TargetDisposition: dto.WindowDispositionNewPopup,
5152 NoJavaScriptAccess: false,
52- RequestContextDisposition: port.RequestContextInheritParent,
53+ RequestContextDisposition: dto.RequestContextInheritParent,
5354 },
54- wantKind: port.HostDecisionCreatePane,
55+ wantKind: dto.HostDecisionCreatePane,
5556 },
5657 {
5758 name: "auth intent forces native window",
58- request: port.NewBrowsingContextRequest{
59+ request: dto.NewBrowsingContextRequest{
5960 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
60- TriggerKind: port.TriggerScriptWindowOpen,
61- TargetDisposition: port.WindowDispositionNewPopup,
61+ TriggerKind: dto.TriggerScriptWindowOpen,
62+ TargetDisposition: dto.WindowDispositionNewPopup,
6263 AuthIntent: true,
63- RequestContextDisposition: port.RequestContextInheritParent,
64+ RequestContextDisposition: dto.RequestContextInheritParent,
6465 },
65- wantKind: port.HostDecisionCreateNativeWin,
66+ wantKind: dto.HostDecisionCreateNativeWin,
6667 },
6768 {
6869 name: "ambiguous opener-coupled request prefers native",
69- request: port.NewBrowsingContextRequest{
70+ request: dto.NewBrowsingContextRequest{
7071 TargetURI: "https://example.com/popup",
71- TriggerKind: port.TriggerUnknown,
72- TargetDisposition: port.WindowDispositionNewPopup,
72+ TriggerKind: dto.TriggerUnknown,
73+ TargetDisposition: dto.WindowDispositionNewPopup,
7374 NoJavaScriptAccess: false,
74- RequestContextDisposition: port.RequestContextInheritParent,
75+ RequestContextDisposition: dto.RequestContextInheritParent,
7576 },
76- wantKind: port.HostDecisionCreateNativeWin,
77+ wantKind: dto.HostDecisionCreateNativeWin,
7778 },
7879 {
7980 name: "empty target is denied",
80- request: port.NewBrowsingContextRequest{
81+ request: dto.NewBrowsingContextRequest{
8182 TargetURI: "",
82- TriggerKind: port.TriggerLinkNewPage,
83- RequestContextDisposition: port.RequestContextInheritParent,
83+ TriggerKind: dto.TriggerLinkNewPage,
84+ RequestContextDisposition: dto.RequestContextInheritParent,
8485 },
85- wantKind: port.HostDecisionDeny,
86+ wantKind: dto.HostDecisionDeny,
8687 },
8788 }
8889
@@ -92,7 +93,7 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
9293 got := policy.Decide(tt.request, tt.namedContextExist)
9394 assert.Equal(t, tt.wantKind, got.Kind)
9495 assert.Equal(t, tt.wantName, got.BrowsingContextName)
95- assert.Equal(t, port.RequestContextInheritParent, got.RequestContextDisposition)
96+ assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
9697 })
9798 }
9899 }
@@ -103,10 +104,10 @@ func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal
103104 req := port.PopupRequest{
104105 TargetURI: "https://example.com/window-open",
105106 NoJavaScriptAccess: false,
106- TargetDisposition: port.WindowDispositionNewPopup,
107+ TargetDisposition: dto.WindowDispositionNewPopup,
107108 }
108109
109- assert.Equal(t, port.TriggerScriptWindowOpen, inferPopupTriggerKind(req))
110+ assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req))
110111 }
111112
112113 func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
@@ -115,24 +116,24 @@ func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
115116 tests := []struct {
116117 name string
117118 req port.PopupRequest
118- want port.TriggerKind
119+ want dto.TriggerKind
119120 }{
120121 {
121122 name: "unnamed current-tab request stays a navigation",
122123 req: port.PopupRequest{
123124 TargetURI: "https://example.com/docs",
124- TargetDisposition: port.WindowDispositionCurrentTab,
125+ TargetDisposition: dto.WindowDispositionCurrentTab,
125126 },
126- want: port.TriggerLinkNewPage,
127+ want: dto.TriggerLinkNewPage,
127128 },
128129 {
129130 name: "named current-tab request is treated as named target navigation",
130131 req: port.PopupRequest{
131132 TargetURI: "https://example.com/docs",
132133 FrameName: "shared-pane",
133- TargetDisposition: port.WindowDispositionCurrentTab,
134+ TargetDisposition: dto.WindowDispositionCurrentTab,
134135 },
135- want: port.TriggerNamedTargetNavigation,
136+ want: dto.TriggerNamedTargetNavigation,
136137 },
137138 }
138139
@@ -150,17 +151,17 @@ func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) {
150151 tests := []struct {
151152 name string
152153 req port.PopupRequest
153- want port.WindowDisposition
154+ want dto.WindowDisposition
154155 }{
155156 {
156157 name: "mixed-case blank target is treated as new tab",
157158 req: port.PopupRequest{FrameName: " _BlAnK "},
158- want: port.WindowDispositionNewTab,
159+ want: dto.WindowDispositionNewTab,
159160 },
160161 {
161162 name: "named target still defaults to popup",
162163 req: port.PopupRequest{FrameName: "shared-pane"},
163- want: port.WindowDispositionNewPopup,
164+ want: dto.WindowDispositionNewPopup,
164165 },
165166 }
166167
@@ -3,6 +3,7 @@ package content
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
8 )9 )
@@ -13,76 +14,76 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
13 policy := browsingContextPolicy{}14 policy := browsingContextPolicy{}
14 tests := []struct {15 tests := []struct {
15 name string16 name string
16- request port.NewBrowsingContextRequest17+ request dto.NewBrowsingContextRequest
17 namedContextExist bool18 namedContextExist bool
18- wantKind port.HostDecisionKind19+ wantKind dto.HostDecisionKind
19 wantName string20 wantName string
20 }{21 }{
21 {22 {
22 name: "blank target stays pane-hosted",23 name: "blank target stays pane-hosted",
23- request: port.NewBrowsingContextRequest{24+ request: dto.NewBrowsingContextRequest{
24 TargetURI: "https://example.com/docs",25 TargetURI: "https://example.com/docs",
25 TargetFrameName: "_blank",26 TargetFrameName: "_blank",
26- TriggerKind: port.TriggerLinkNewPage,27+ TriggerKind: dto.TriggerLinkNewPage,
27- TargetDisposition: port.WindowDispositionNewTab,28+ TargetDisposition: dto.WindowDispositionNewTab,
28- RequestContextDisposition: port.RequestContextInheritParent,29+ RequestContextDisposition: dto.RequestContextInheritParent,
29 },30 },
30- wantKind: port.HostDecisionCreatePane,31+ wantKind: dto.HostDecisionCreatePane,
31 },32 },
32 {33 {
33 name: "named target reuses existing pane in same window",34 name: "named target reuses existing pane in same window",
34- request: port.NewBrowsingContextRequest{35+ request: dto.NewBrowsingContextRequest{
35 TargetURI: "https://example.com/second",36 TargetURI: "https://example.com/second",
36 TargetFrameName: "shared-pane",37 TargetFrameName: "shared-pane",
37- TriggerKind: port.TriggerNamedTargetNavigation,38+ TriggerKind: dto.TriggerNamedTargetNavigation,
38- TargetDisposition: port.WindowDispositionNewPopup,39+ TargetDisposition: dto.WindowDispositionNewPopup,
39- RequestContextDisposition: port.RequestContextInheritParent,40+ RequestContextDisposition: dto.RequestContextInheritParent,
40 },41 },
41 namedContextExist: true,42 namedContextExist: true,
42- wantKind: port.HostDecisionReuseNamedPane,43+ wantKind: dto.HostDecisionReuseNamedPane,
43 wantName: "shared-pane",44 wantName: "shared-pane",
44 },45 },
45 {46 {
46 name: "ordinary window.open stays pane-hosted",47 name: "ordinary window.open stays pane-hosted",
47- request: port.NewBrowsingContextRequest{48+ request: dto.NewBrowsingContextRequest{
48 TargetURI: "https://example.com/help",49 TargetURI: "https://example.com/help",
49- TriggerKind: port.TriggerScriptWindowOpen,50+ TriggerKind: dto.TriggerScriptWindowOpen,
50- TargetDisposition: port.WindowDispositionNewPopup,51+ TargetDisposition: dto.WindowDispositionNewPopup,
51 NoJavaScriptAccess: false,52 NoJavaScriptAccess: false,
52- RequestContextDisposition: port.RequestContextInheritParent,53+ RequestContextDisposition: dto.RequestContextInheritParent,
53 },54 },
54- wantKind: port.HostDecisionCreatePane,55+ wantKind: dto.HostDecisionCreatePane,
55 },56 },
56 {57 {
57 name: "auth intent forces native window",58 name: "auth intent forces native window",
58- request: port.NewBrowsingContextRequest{59+ request: dto.NewBrowsingContextRequest{
59 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",60 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
60- TriggerKind: port.TriggerScriptWindowOpen,61+ TriggerKind: dto.TriggerScriptWindowOpen,
61- TargetDisposition: port.WindowDispositionNewPopup,62+ TargetDisposition: dto.WindowDispositionNewPopup,
62 AuthIntent: true,63 AuthIntent: true,
63- RequestContextDisposition: port.RequestContextInheritParent,64+ RequestContextDisposition: dto.RequestContextInheritParent,
64 },65 },
65- wantKind: port.HostDecisionCreateNativeWin,66+ wantKind: dto.HostDecisionCreateNativeWin,
66 },67 },
67 {68 {
68 name: "ambiguous opener-coupled request prefers native",69 name: "ambiguous opener-coupled request prefers native",
69- request: port.NewBrowsingContextRequest{70+ request: dto.NewBrowsingContextRequest{
70 TargetURI: "https://example.com/popup",71 TargetURI: "https://example.com/popup",
71- TriggerKind: port.TriggerUnknown,72+ TriggerKind: dto.TriggerUnknown,
72- TargetDisposition: port.WindowDispositionNewPopup,73+ TargetDisposition: dto.WindowDispositionNewPopup,
73 NoJavaScriptAccess: false,74 NoJavaScriptAccess: false,
74- RequestContextDisposition: port.RequestContextInheritParent,75+ RequestContextDisposition: dto.RequestContextInheritParent,
75 },76 },
76- wantKind: port.HostDecisionCreateNativeWin,77+ wantKind: dto.HostDecisionCreateNativeWin,
77 },78 },
78 {79 {
79 name: "empty target is denied",80 name: "empty target is denied",
80- request: port.NewBrowsingContextRequest{81+ request: dto.NewBrowsingContextRequest{
81 TargetURI: "",82 TargetURI: "",
82- TriggerKind: port.TriggerLinkNewPage,83+ TriggerKind: dto.TriggerLinkNewPage,
83- RequestContextDisposition: port.RequestContextInheritParent,84+ RequestContextDisposition: dto.RequestContextInheritParent,
84 },85 },
85- wantKind: port.HostDecisionDeny,86+ wantKind: dto.HostDecisionDeny,
86 },87 },
87 }88 }
88 89
@@ -92,7 +93,7 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
92 got := policy.Decide(tt.request, tt.namedContextExist)93 got := policy.Decide(tt.request, tt.namedContextExist)
93 assert.Equal(t, tt.wantKind, got.Kind)94 assert.Equal(t, tt.wantKind, got.Kind)
94 assert.Equal(t, tt.wantName, got.BrowsingContextName)95 assert.Equal(t, tt.wantName, got.BrowsingContextName)
95- assert.Equal(t, port.RequestContextInheritParent, got.RequestContextDisposition)96+ assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
96 })97 })
97 }98 }
98 }99 }
@@ -103,10 +104,10 @@ func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal
103 req := port.PopupRequest{104 req := port.PopupRequest{
104 TargetURI: "https://example.com/window-open",105 TargetURI: "https://example.com/window-open",
105 NoJavaScriptAccess: false,106 NoJavaScriptAccess: false,
106- TargetDisposition: port.WindowDispositionNewPopup,107+ TargetDisposition: dto.WindowDispositionNewPopup,
107 }108 }
108 109
109- assert.Equal(t, port.TriggerScriptWindowOpen, inferPopupTriggerKind(req))110+ assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req))
110 }111 }
111 112
112 func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {113 func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
@@ -115,24 +116,24 @@ func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
115 tests := []struct {116 tests := []struct {
116 name string117 name string
117 req port.PopupRequest118 req port.PopupRequest
118- want port.TriggerKind119+ want dto.TriggerKind
119 }{120 }{
120 {121 {
121 name: "unnamed current-tab request stays a navigation",122 name: "unnamed current-tab request stays a navigation",
122 req: port.PopupRequest{123 req: port.PopupRequest{
123 TargetURI: "https://example.com/docs",124 TargetURI: "https://example.com/docs",
124- TargetDisposition: port.WindowDispositionCurrentTab,125+ TargetDisposition: dto.WindowDispositionCurrentTab,
125 },126 },
126- want: port.TriggerLinkNewPage,127+ want: dto.TriggerLinkNewPage,
127 },128 },
128 {129 {
129 name: "named current-tab request is treated as named target navigation",130 name: "named current-tab request is treated as named target navigation",
130 req: port.PopupRequest{131 req: port.PopupRequest{
131 TargetURI: "https://example.com/docs",132 TargetURI: "https://example.com/docs",
132 FrameName: "shared-pane",133 FrameName: "shared-pane",
133- TargetDisposition: port.WindowDispositionCurrentTab,134+ TargetDisposition: dto.WindowDispositionCurrentTab,
134 },135 },
135- want: port.TriggerNamedTargetNavigation,136+ want: dto.TriggerNamedTargetNavigation,
136 },137 },
137 }138 }
138 139
@@ -150,17 +151,17 @@ func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) {
150 tests := []struct {151 tests := []struct {
151 name string152 name string
152 req port.PopupRequest153 req port.PopupRequest
153- want port.WindowDisposition154+ want dto.WindowDisposition
154 }{155 }{
155 {156 {
156 name: "mixed-case blank target is treated as new tab",157 name: "mixed-case blank target is treated as new tab",
157 req: port.PopupRequest{FrameName: " _BlAnK "},158 req: port.PopupRequest{FrameName: " _BlAnK "},
158- want: port.WindowDispositionNewTab,159+ want: dto.WindowDispositionNewTab,
159 },160 },
160 {161 {
161 name: "named target still defaults to popup",162 name: "named target still defaults to popup",
162 req: port.PopupRequest{FrameName: "shared-pane"},163 req: port.PopupRequest{FrameName: "shared-pane"},
163- want: port.WindowDispositionNewPopup,164+ want: dto.WindowDispositionNewPopup,
164 },165 },
165 }166 }
166 167
M internal/ui/coordinator/content/oauth_test.go +34 -0
@@ -148,6 +148,40 @@ func TestObserveNativePopupAuth_DoesNotTrackWhenWebViewLacksOAuthCallbacks(t *te
148148 assert.False(t, ok)
149149 }
150150
151+func TestObserveNativePopupAuth_SchedulesCloseOnlyOnce(t *testing.T) {
152+ popupID := port.WebViewID(1002)
153+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
154+ wv.EXPECT().ID().Return(popupID).Once()
155+ wv.EXPECT().IsDestroyed().Return(false).Once()
156+
157+ oldSchedule := scheduleNativePopupOAuthClose
158+ t.Cleanup(func() {
159+ scheduleNativePopupOAuthClose = oldSchedule
160+ })
161+
162+ scheduledCalls := 0
163+ scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
164+ scheduledCalls++
165+ assert.Equal(t, nativePopupOAuthCloseDelay, delay)
166+ fn()
167+ }
168+
169+ c := &Coordinator{popups: newPopupManager()}
170+ c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
171+ ParentPaneID: entity.PaneID("parent-pane"),
172+ ParentURIAtOpen: "https://x.com/i/flow/login",
173+ PopupWebView: wv,
174+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
175+ })
176+ require.Len(t, wv.navigationCallbacks, 1)
177+
178+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
179+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
180+
181+ assert.Equal(t, 1, scheduledCalls)
182+ assert.Equal(t, 1, wv.closeCount)
183+}
184+
151185 func TestSetupOAuthAutoClose_NavigationIgnoresNonTerminalURI(t *testing.T) {
152186 popupID := port.WebViewID(1000)
153187 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
@@ -148,6 +148,40 @@ func TestObserveNativePopupAuth_DoesNotTrackWhenWebViewLacksOAuthCallbacks(t *te
148 assert.False(t, ok)148 assert.False(t, ok)
149 }149 }
150 150
151+func TestObserveNativePopupAuth_SchedulesCloseOnlyOnce(t *testing.T) {
152+ popupID := port.WebViewID(1002)
153+ wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
154+ wv.EXPECT().ID().Return(popupID).Once()
155+ wv.EXPECT().IsDestroyed().Return(false).Once()
156+
157+ oldSchedule := scheduleNativePopupOAuthClose
158+ t.Cleanup(func() {
159+ scheduleNativePopupOAuthClose = oldSchedule
160+ })
161+
162+ scheduledCalls := 0
163+ scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
164+ scheduledCalls++
165+ assert.Equal(t, nativePopupOAuthCloseDelay, delay)
166+ fn()
167+ }
168+
169+ c := &Coordinator{popups: newPopupManager()}
170+ c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
171+ ParentPaneID: entity.PaneID("parent-pane"),
172+ ParentURIAtOpen: "https://x.com/i/flow/login",
173+ PopupWebView: wv,
174+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
175+ })
176+ require.Len(t, wv.navigationCallbacks, 1)
177+
178+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
179+ wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
180+
181+ assert.Equal(t, 1, scheduledCalls)
182+ assert.Equal(t, 1, wv.closeCount)
183+}
184+
151 func TestSetupOAuthAutoClose_NavigationIgnoresNonTerminalURI(t *testing.T) {185 func TestSetupOAuthAutoClose_NavigationIgnoresNonTerminalURI(t *testing.T) {
152 popupID := port.WebViewID(1000)186 popupID := port.WebViewID(1000)
153 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}187 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
M internal/ui/coordinator/content/popup_manager.go +8 -7
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/logging"
@@ -122,7 +123,7 @@ func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBeh
122123 return behavior, placement
123124 }
124125
125-func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision port.HostDecision) {
126+func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision dto.HostDecision) {
126127 if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {
127128 carrier.SetBrowsingContextHostDecision(decision)
128129 }
@@ -426,7 +427,7 @@ func (pm *popupManager) openNativePopup(
426427 parentID port.WebViewID,
427428 parentURIAtOpen string,
428429 req port.PopupRequest,
429- decision port.HostDecision,
430+ decision dto.HostDecision,
430431 ) port.WebView {
431432 log := logging.FromContext(ctx)
432433 if pm.onOpenNativePopup == nil {
@@ -500,9 +501,9 @@ func (pm *popupManager) handlePopupCreate(
500501 Msg("browsing context host decision")
501502
502503 switch decision.Kind {
503- case port.HostDecisionDeny:
504+ case dto.HostDecisionDeny:
504505 return nil
505- case port.HostDecisionReuseNamedPane:
506+ case dto.HostDecisionReuseNamedPane:
506507 if req.NoJavaScriptAccess {
507508 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")
508509 return nil
@@ -514,9 +515,9 @@ func (pm *popupManager) handlePopupCreate(
514515 }
515516 pm.setBrowsingContextDecision(reused, decision)
516517 return reused
517- case port.HostDecisionCreateNativeWin:
518+ case dto.HostDecisionCreateNativeWin:
518519 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
519- case port.HostDecisionCreatePane:
520+ case dto.HostDecisionCreatePane:
520521 // Continue below.
521522 break
522523 }
@@ -812,7 +813,7 @@ func (pm *popupManager) handleLinkMiddleClick(
812813 Str("decision", string(decision.Kind)).
813814 Str("reason", decision.Reason).
814815 Msg("middle-click browsing context host decision")
815- if decision.Kind != port.HostDecisionCreatePane {
816+ if decision.Kind != dto.HostDecisionCreatePane {
816817 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
817818 return false
818819 }
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
@@ -122,7 +123,7 @@ func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBeh
122 return behavior, placement123 return behavior, placement
123 }124 }
124 125
125-func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision port.HostDecision) {126+func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision dto.HostDecision) {
126 if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {127 if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {
127 carrier.SetBrowsingContextHostDecision(decision)128 carrier.SetBrowsingContextHostDecision(decision)
128 }129 }
@@ -426,7 +427,7 @@ func (pm *popupManager) openNativePopup(
426 parentID port.WebViewID,427 parentID port.WebViewID,
427 parentURIAtOpen string,428 parentURIAtOpen string,
428 req port.PopupRequest,429 req port.PopupRequest,
429- decision port.HostDecision,430+ decision dto.HostDecision,
430 ) port.WebView {431 ) port.WebView {
431 log := logging.FromContext(ctx)432 log := logging.FromContext(ctx)
432 if pm.onOpenNativePopup == nil {433 if pm.onOpenNativePopup == nil {
@@ -500,9 +501,9 @@ func (pm *popupManager) handlePopupCreate(
500 Msg("browsing context host decision")501 Msg("browsing context host decision")
501 502
502 switch decision.Kind {503 switch decision.Kind {
503- case port.HostDecisionDeny:504+ case dto.HostDecisionDeny:
504 return nil505 return nil
505- case port.HostDecisionReuseNamedPane:506+ case dto.HostDecisionReuseNamedPane:
506 if req.NoJavaScriptAccess {507 if req.NoJavaScriptAccess {
507 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")508 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")
508 return nil509 return nil
@@ -514,9 +515,9 @@ func (pm *popupManager) handlePopupCreate(
514 }515 }
515 pm.setBrowsingContextDecision(reused, decision)516 pm.setBrowsingContextDecision(reused, decision)
516 return reused517 return reused
517- case port.HostDecisionCreateNativeWin:518+ case dto.HostDecisionCreateNativeWin:
518 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)519 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
519- case port.HostDecisionCreatePane:520+ case dto.HostDecisionCreatePane:
520 // Continue below.521 // Continue below.
521 break522 break
522 }523 }
@@ -812,7 +813,7 @@ func (pm *popupManager) handleLinkMiddleClick(
812 Str("decision", string(decision.Kind)).813 Str("decision", string(decision.Kind)).
813 Str("reason", decision.Reason).814 Str("reason", decision.Reason).
814 Msg("middle-click browsing context host decision")815 Msg("middle-click browsing context host decision")
815- if decision.Kind != port.HostDecisionCreatePane {816+ if decision.Kind != dto.HostDecisionCreatePane {
816 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")817 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
817 return false818 return false
818 }819 }
M internal/ui/coordinator/content/popup_test.go +14 -4
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
13+ "github.com/bnema/dumber/internal/application/dto"
1314 "github.com/bnema/dumber/internal/application/port"
1415 "github.com/bnema/dumber/internal/application/port/mocks"
1516 "github.com/bnema/dumber/internal/domain/entity"
@@ -361,7 +362,7 @@ func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) {
361362 assert.Equal(t, 1, insertCalls)
362363 decision, hasDecision := newWV.BrowsingContextHostDecision()
363364 require.True(t, hasDecision)
364- assert.Equal(t, port.HostDecisionCreatePane, decision.Kind)
365+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
365366 }
366367
367368 func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
@@ -421,12 +422,19 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
421422 factory := mocks.NewMockWebViewFactory(t)
422423 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
423424
425+ insertCalls := 0
426+ nativeCalls := 0
424427 c := &Coordinator{
425428 webViews: make(map[entity.PaneID]port.WebView),
426429 popups: newPopupManager(),
427430 }
428431 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
432+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
433+ insertCalls++
434+ return nil
435+ })
429436 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
437+ nativeCalls++
430438 assert.False(t, input.ObserveOAuthAutoClose)
431439 return nil
432440 })
@@ -438,6 +446,8 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
438446 })
439447
440448 require.Equal(t, popupWV, created)
449+ assert.Equal(t, 1, nativeCalls)
450+ assert.Equal(t, 0, insertCalls)
441451 }
442452
443453 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
@@ -678,7 +688,7 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
678688 type popupNavigationWebViewStub struct {
679689 *mocks.MockWebView
680690 primed []string
681- browsingContextDecision port.HostDecision
691+ browsingContextDecision dto.HostDecision
682692 hasBrowsingContextDecision bool
683693 }
684694
@@ -686,12 +696,12 @@ func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
686696 s.primed = append(s.primed, uri)
687697 }
688698
689-func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision port.HostDecision) {
699+func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
690700 s.browsingContextDecision = decision
691701 s.hasBrowsingContextDecision = true
692702 }
693703
694-func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (port.HostDecision, bool) {
704+func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
695705 return s.browsingContextDecision, s.hasBrowsingContextDecision
696706 }
697707
@@ -10,6 +10,7 @@ import (
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13+ "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/port/mocks"15 "github.com/bnema/dumber/internal/application/port/mocks"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
@@ -361,7 +362,7 @@ func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) {
361 assert.Equal(t, 1, insertCalls)362 assert.Equal(t, 1, insertCalls)
362 decision, hasDecision := newWV.BrowsingContextHostDecision()363 decision, hasDecision := newWV.BrowsingContextHostDecision()
363 require.True(t, hasDecision)364 require.True(t, hasDecision)
364- assert.Equal(t, port.HostDecisionCreatePane, decision.Kind)365+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
365 }366 }
366 367
367 func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {368 func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
@@ -421,12 +422,19 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
421 factory := mocks.NewMockWebViewFactory(t)422 factory := mocks.NewMockWebViewFactory(t)
422 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()423 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
423 424
425+ insertCalls := 0
426+ nativeCalls := 0
424 c := &Coordinator{427 c := &Coordinator{
425 webViews: make(map[entity.PaneID]port.WebView),428 webViews: make(map[entity.PaneID]port.WebView),
426 popups: newPopupManager(),429 popups: newPopupManager(),
427 }430 }
428 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)431 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
432+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
433+ insertCalls++
434+ return nil
435+ })
429 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {436 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
437+ nativeCalls++
430 assert.False(t, input.ObserveOAuthAutoClose)438 assert.False(t, input.ObserveOAuthAutoClose)
431 return nil439 return nil
432 })440 })
@@ -438,6 +446,8 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
438 })446 })
439 447
440 require.Equal(t, popupWV, created)448 require.Equal(t, popupWV, created)
449+ assert.Equal(t, 1, nativeCalls)
450+ assert.Equal(t, 0, insertCalls)
441 }451 }
442 452
443 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {453 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
@@ -678,7 +688,7 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
678 type popupNavigationWebViewStub struct {688 type popupNavigationWebViewStub struct {
679 *mocks.MockWebView689 *mocks.MockWebView
680 primed []string690 primed []string
681- browsingContextDecision port.HostDecision691+ browsingContextDecision dto.HostDecision
682 hasBrowsingContextDecision bool692 hasBrowsingContextDecision bool
683 }693 }
684 694
@@ -686,12 +696,12 @@ func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
686 s.primed = append(s.primed, uri)696 s.primed = append(s.primed, uri)
687 }697 }
688 698
689-func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision port.HostDecision) {699+func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
690 s.browsingContextDecision = decision700 s.browsingContextDecision = decision
691 s.hasBrowsingContextDecision = true701 s.hasBrowsingContextDecision = true
692 }702 }
693 703
694-func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (port.HostDecision, bool) {704+func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
695 return s.browsingContextDecision, s.hasBrowsingContextDecision705 return s.browsingContextDecision, s.hasBrowsingContextDecision
696 }706 }
697 707
M internal/ui/native_popup_window.go +4 -1
@@ -27,6 +27,8 @@ type nativePopupDestroyer interface {
2727 Destroy()
2828 }
2929
30+var newPopupWindow = window.NewPopup
31+
3032 func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {
3133 if wv != nil && !wv.IsDestroyed() {
3234 wv.Destroy()
@@ -63,8 +65,9 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
6365 return fmt.Errorf("native popup webview is nil")
6466 }
6567
66- popupShell, err := window.NewPopup(ctx, a.gtkApp)
68+ popupShell, err := newPopupWindow(ctx, a.gtkApp)
6769 if err != nil {
70+ destroyFailedNativePopupSetup(nil, input.PopupWebView)
6871 return err
6972 }
7073 if a.contentCoord == nil {
@@ -27,6 +27,8 @@ type nativePopupDestroyer interface {
27 Destroy()27 Destroy()
28 }28 }
29 29
30+var newPopupWindow = window.NewPopup
31+
30 func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {32 func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {
31 if wv != nil && !wv.IsDestroyed() {33 if wv != nil && !wv.IsDestroyed() {
32 wv.Destroy()34 wv.Destroy()
@@ -63,8 +65,9 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
63 return fmt.Errorf("native popup webview is nil")65 return fmt.Errorf("native popup webview is nil")
64 }66 }
65 67
66- popupShell, err := window.NewPopup(ctx, a.gtkApp)68+ popupShell, err := newPopupWindow(ctx, a.gtkApp)
67 if err != nil {69 if err != nil {
70+ destroyFailedNativePopupSetup(nil, input.PopupWebView)
68 return err71 return err
69 }72 }
70 if a.contentCoord == nil {73 if a.contentCoord == nil {
M internal/ui/native_popup_window_test.go +24 -0
@@ -1,11 +1,15 @@
11 package ui
22
33 import (
4+ "context"
5+ "errors"
46 "testing"
57
68 "github.com/bnema/dumber/internal/application/port"
79 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/ui/coordinator/content"
811 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
12+ "github.com/bnema/dumber/internal/ui/window"
913 "github.com/bnema/puregotk/v4/gtk"
1014 "github.com/stretchr/testify/assert"
1115 "github.com/stretchr/testify/require"
@@ -40,6 +44,26 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te
4044 assert.Nil(t, got)
4145 }
4246
47+func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {
48+ oldNewPopupWindow := newPopupWindow
49+ t.Cleanup(func() {
50+ newPopupWindow = oldNewPopupWindow
51+ })
52+
53+ expectedErr := errors.New("boom")
54+ newPopupWindow = func(context.Context, *gtk.Application) (*window.PopupWindow, error) {
55+ return nil, expectedErr
56+ }
57+
58+ wv := portmocks.NewMockWebView(t)
59+ wv.EXPECT().IsDestroyed().Return(false).Once()
60+ wv.EXPECT().Destroy().Once()
61+
62+ app := &App{gtkApp: &gtk.Application{}}
63+ err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
64+ require.ErrorIs(t, err, expectedErr)
65+}
66+
4367 func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {
4468 wv := portmocks.NewMockWebView(t)
4569 wv.EXPECT().IsDestroyed().Return(false).Once()
@@ -1,11 +1,15 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "context"
5+ "errors"
4 "testing"6 "testing"
5 7
6 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
7 portmocks "github.com/bnema/dumber/internal/application/port/mocks"9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/ui/coordinator/content"
8 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"11 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
12+ "github.com/bnema/dumber/internal/ui/window"
9 "github.com/bnema/puregotk/v4/gtk"13 "github.com/bnema/puregotk/v4/gtk"
10 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
@@ -40,6 +44,26 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te
40 assert.Nil(t, got)44 assert.Nil(t, got)
41 }45 }
42 46
47+func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {
48+ oldNewPopupWindow := newPopupWindow
49+ t.Cleanup(func() {
50+ newPopupWindow = oldNewPopupWindow
51+ })
52+
53+ expectedErr := errors.New("boom")
54+ newPopupWindow = func(context.Context, *gtk.Application) (*window.PopupWindow, error) {
55+ return nil, expectedErr
56+ }
57+
58+ wv := portmocks.NewMockWebView(t)
59+ wv.EXPECT().IsDestroyed().Return(false).Once()
60+ wv.EXPECT().Destroy().Once()
61+
62+ app := &App{gtkApp: &gtk.Application{}}
63+ err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
64+ require.ErrorIs(t, err, expectedErr)
65+}
66+
43 func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {67 func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {
44 wv := portmocks.NewMockWebView(t)68 wv := portmocks.NewMockWebView(t)
45 wv.EXPECT().IsDestroyed().Return(false).Once()69 wv.EXPECT().IsDestroyed().Return(false).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