detached 3101cce

refactor(cef): simplify popup bridge handling

commit
3101cceb3e7c4c49586e5929c4ae5db4fd460ec4
author
brice · · 2026-04-21 14:21 UTC
parent
830fe74
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +250 -142
M internal/application/port/webview.go +5 -0
@@ -5,11 +5,16 @@ package port
55
66 import (
77 "context"
8+ "errors"
89 )
910
1011 // WebViewID uniquely identifies a WebView instance.
1112 type WebViewID uint64
1213
14+// ErrRelatedWebViewUnsupported reports that the active engine cannot create
15+// a popup WebView related to an existing parent WebView.
16+var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
17+
1318 // LoadEvent represents page load state transitions.
1419 type LoadEvent int
1520
@@ -5,11 +5,16 @@ package port
5 5
6 import (6 import (
7 "context"7 "context"
8+ "errors"
8 )9 )
9 10
10 // WebViewID uniquely identifies a WebView instance.11 // WebViewID uniquely identifies a WebView instance.
11 type WebViewID uint6412 type WebViewID uint64
12 13
14+// ErrRelatedWebViewUnsupported reports that the active engine cannot create
15+// a popup WebView related to an existing parent WebView.
16+var ErrRelatedWebViewUnsupported = errors.New("related popup webviews are not supported")
17+
13 // LoadEvent represents page load state transitions.18 // LoadEvent represents page load state transitions.
14 type LoadEvent int19 type LoadEvent int
15 20
M internal/infrastructure/cef/engine.go +34 -24
@@ -49,8 +49,9 @@ type Engine struct {
4949 alreadyRunningAppRelaunchHandler func(string)
5050
5151 // activeWebViews tracks all live webviews for CSS broadcast.
52- activeWebViews sync.Map // map[port.WebViewID]*WebView
53- activeCount atomic.Int32
52+ activeWebViews sync.Map // map[port.WebViewID]*WebView
53+ browserWebViews sync.Map // map[int32]*WebView
54+ activeCount atomic.Int32
5455
5556 // shutdownNotify is signaled when the active webview count reaches 0
5657 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
@@ -145,10 +146,29 @@ func (e *Engine) registerWebView(wv *WebView) {
145146 e.activeCount.Add(1)
146147 }
147148
149+func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
150+ if e == nil || browser == nil || wv == nil {
151+ return
152+ }
153+ e.browserWebViews.Store(browser.GetIdentifier(), wv)
154+}
155+
156+func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
157+ if e == nil || browserID == 0 {
158+ return
159+ }
160+ if current, ok := e.browserWebViews.Load(browserID); ok {
161+ if existing, ok := current.(*WebView); ok && existing == wv {
162+ e.browserWebViews.Delete(browserID)
163+ }
164+ }
165+}
166+
148167 // unregisterWebView removes a webview from the active tracking map.
149168 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
150-func (e *Engine) unregisterWebView(wv *WebView) {
169+func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
151170 e.activeWebViews.Delete(wv.id)
171+ e.unbindBrowserWebView(browserID, wv)
152172 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
153173 select {
154174 case e.shutdownNotify <- struct{}{}:
@@ -432,28 +452,18 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
432452 return false
433453 }
434454
435- browserID := browser.GetIdentifier()
436- valid := false
437- e.activeWebViews.Range(func(_, value any) bool {
438- wv, ok := value.(*WebView)
439- if !ok || wv == nil {
440- return true
441- }
455+ wv := e.webViewForBrowser(browser)
456+ if wv == nil {
457+ return false
458+ }
442459
443- wv.mu.RLock()
444- wvBrowser := wv.browser
445- wvBridgeNonce := wv.bridgeNonce
446- wv.mu.RUnlock()
447- if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {
448- return true
449- }
450- if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
451- valid = true
452- return false
453- }
454- return true
455- })
456- return valid
460+ wv.mu.RLock()
461+ wvBridgeNonce := wv.bridgeNonce
462+ wv.mu.RUnlock()
463+ if wvBridgeNonce == "" {
464+ return false
465+ }
466+ return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1
457467 }
458468
459469 func (e *Engine) currentDownloadHandler() *downloadHandler {
@@ -49,8 +49,9 @@ type Engine struct {
49 alreadyRunningAppRelaunchHandler func(string)49 alreadyRunningAppRelaunchHandler func(string)
50 50
51 // activeWebViews tracks all live webviews for CSS broadcast.51 // activeWebViews tracks all live webviews for CSS broadcast.
52- activeWebViews sync.Map // map[port.WebViewID]*WebView52+ activeWebViews sync.Map // map[port.WebViewID]*WebView
53- activeCount atomic.Int3253+ browserWebViews sync.Map // map[int32]*WebView
54+ activeCount atomic.Int32
54 55
55 // shutdownNotify is signaled when the active webview count reaches 056 // shutdownNotify is signaled when the active webview count reaches 0
56 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.57 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
@@ -145,10 +146,29 @@ func (e *Engine) registerWebView(wv *WebView) {
145 e.activeCount.Add(1)146 e.activeCount.Add(1)
146 }147 }
147 148
149+func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
150+ if e == nil || browser == nil || wv == nil {
151+ return
152+ }
153+ e.browserWebViews.Store(browser.GetIdentifier(), wv)
154+}
155+
156+func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
157+ if e == nil || browserID == 0 {
158+ return
159+ }
160+ if current, ok := e.browserWebViews.Load(browserID); ok {
161+ if existing, ok := current.(*WebView); ok && existing == wv {
162+ e.browserWebViews.Delete(browserID)
163+ }
164+ }
165+}
166+
148 // unregisterWebView removes a webview from the active tracking map.167 // unregisterWebView removes a webview from the active tracking map.
149 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.168 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
150-func (e *Engine) unregisterWebView(wv *WebView) {169+func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
151 e.activeWebViews.Delete(wv.id)170 e.activeWebViews.Delete(wv.id)
171+ e.unbindBrowserWebView(browserID, wv)
152 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {172 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
153 select {173 select {
154 case e.shutdownNotify <- struct{}{}:174 case e.shutdownNotify <- struct{}{}:
@@ -432,28 +452,18 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
432 return false452 return false
433 }453 }
434 454
435- browserID := browser.GetIdentifier()455+ wv := e.webViewForBrowser(browser)
436- valid := false456+ if wv == nil {
437- e.activeWebViews.Range(func(_, value any) bool {457+ return false
438- wv, ok := value.(*WebView)458+ }
439- if !ok || wv == nil {
440- return true
441- }
442 459
443- wv.mu.RLock()460+ wv.mu.RLock()
444- wvBrowser := wv.browser461+ wvBridgeNonce := wv.bridgeNonce
445- wvBridgeNonce := wv.bridgeNonce462+ wv.mu.RUnlock()
446- wv.mu.RUnlock()463+ if wvBridgeNonce == "" {
447- if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {464+ return false
448- return true465+ }
449- }466+ return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1
450- if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
451- valid = true
452- return false
453- }
454- return true
455- })
456- return valid
457 }467 }
458 468
459 func (e *Engine) currentDownloadHandler() *downloadHandler {469 func (e *Engine) currentDownloadHandler() *downloadHandler {
M internal/infrastructure/cef/engine_popup_bridge.go +30 -29
@@ -12,6 +12,18 @@ func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
1212 }
1313
1414 browserID := browser.GetIdentifier()
15+ if current, ok := e.browserWebViews.Load(browserID); ok {
16+ if wv, ok := current.(*WebView); ok && wv != nil {
17+ wv.mu.RLock()
18+ wvBrowser := wv.browser
19+ wv.mu.RUnlock()
20+ if wvBrowser != nil && wvBrowser.GetIdentifier() == browserID {
21+ return wv
22+ }
23+ e.unbindBrowserWebView(browserID, wv)
24+ }
25+ }
26+
1527 var matched *WebView
1628 e.activeWebViews.Range(func(_, value any) bool {
1729 wv, ok := value.(*WebView)
@@ -25,52 +37,41 @@ func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
2537 return true
2638 }
2739 matched = wv
40+ e.bindBrowserWebView(browser, wv)
2841 return false
2942 })
3043 return matched
3144 }
3245
33-func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
34- if e == nil {
46+func (e *Engine) withBridgeSourceWebView(browser purecef.Browser, proxyID, action string, fn func(*WebView)) {
47+ if e == nil || browser == nil {
3548 return
3649 }
3750 wv := e.webViewForBrowser(browser)
3851 if wv == nil {
3952 logging.FromContext(e.currentContext()).Warn().
4053 Int32("browser_id", browser.GetIdentifier()).
41- Str("proxy_id", payload.ProxyID).
42- Msg("cef: popup-open bridge could not resolve source webview")
54+ Str("proxy_id", proxyID).
55+ Msg("cef: " + action + " bridge could not resolve source webview")
4356 return
4457 }
45- wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
58+ fn(wv)
59+}
60+
61+func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
62+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-open", func(wv *WebView) {
63+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
64+ })
4665 }
4766
4867 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
49- if e == nil {
50- return
51- }
52- wv := e.webViewForBrowser(browser)
53- if wv == nil {
54- logging.FromContext(e.currentContext()).Warn().
55- Int32("browser_id", browser.GetIdentifier()).
56- Str("proxy_id", payload.ProxyID).
57- Msg("cef: popup-navigate bridge could not resolve source webview")
58- return
59- }
60- wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
68+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-navigate", func(wv *WebView) {
69+ wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
70+ })
6171 }
6272
6373 func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {
64- if e == nil {
65- return
66- }
67- wv := e.webViewForBrowser(browser)
68- if wv == nil {
69- logging.FromContext(e.currentContext()).Warn().
70- Int32("browser_id", browser.GetIdentifier()).
71- Str("proxy_id", payload.ProxyID).
72- Msg("cef: popup-close bridge could not resolve source webview")
73- return
74- }
75- wv.handleSyntheticPopupClose(payload.ProxyID)
74+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-close", func(wv *WebView) {
75+ wv.handleSyntheticPopupClose(payload.ProxyID)
76+ })
7677 }
@@ -12,6 +12,18 @@ func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
12 }12 }
13 13
14 browserID := browser.GetIdentifier()14 browserID := browser.GetIdentifier()
15+ if current, ok := e.browserWebViews.Load(browserID); ok {
16+ if wv, ok := current.(*WebView); ok && wv != nil {
17+ wv.mu.RLock()
18+ wvBrowser := wv.browser
19+ wv.mu.RUnlock()
20+ if wvBrowser != nil && wvBrowser.GetIdentifier() == browserID {
21+ return wv
22+ }
23+ e.unbindBrowserWebView(browserID, wv)
24+ }
25+ }
26+
15 var matched *WebView27 var matched *WebView
16 e.activeWebViews.Range(func(_, value any) bool {28 e.activeWebViews.Range(func(_, value any) bool {
17 wv, ok := value.(*WebView)29 wv, ok := value.(*WebView)
@@ -25,52 +37,41 @@ func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView {
25 return true37 return true
26 }38 }
27 matched = wv39 matched = wv
40+ e.bindBrowserWebView(browser, wv)
28 return false41 return false
29 })42 })
30 return matched43 return matched
31 }44 }
32 45
33-func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {46+func (e *Engine) withBridgeSourceWebView(browser purecef.Browser, proxyID, action string, fn func(*WebView)) {
34- if e == nil {47+ if e == nil || browser == nil {
35 return48 return
36 }49 }
37 wv := e.webViewForBrowser(browser)50 wv := e.webViewForBrowser(browser)
38 if wv == nil {51 if wv == nil {
39 logging.FromContext(e.currentContext()).Warn().52 logging.FromContext(e.currentContext()).Warn().
40 Int32("browser_id", browser.GetIdentifier()).53 Int32("browser_id", browser.GetIdentifier()).
41- Str("proxy_id", payload.ProxyID).54+ Str("proxy_id", proxyID).
42- Msg("cef: popup-open bridge could not resolve source webview")55+ Msg("cef: " + action + " bridge could not resolve source webview")
43 return56 return
44 }57 }
45- wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)58+ fn(wv)
59+}
60+
61+func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) {
62+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-open", func(wv *WebView) {
63+ wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess)
64+ })
46 }65 }
47 66
48 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {67 func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) {
49- if e == nil {68+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-navigate", func(wv *WebView) {
50- return69+ wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
51- }70+ })
52- wv := e.webViewForBrowser(browser)
53- if wv == nil {
54- logging.FromContext(e.currentContext()).Warn().
55- Int32("browser_id", browser.GetIdentifier()).
56- Str("proxy_id", payload.ProxyID).
57- Msg("cef: popup-navigate bridge could not resolve source webview")
58- return
59- }
60- wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL)
61 }71 }
62 72
63 func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {73 func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) {
64- if e == nil {74+ e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-close", func(wv *WebView) {
65- return75+ wv.handleSyntheticPopupClose(payload.ProxyID)
66- }76+ })
67- wv := e.webViewForBrowser(browser)
68- if wv == nil {
69- logging.FromContext(e.currentContext()).Warn().
70- Int32("browser_id", browser.GetIdentifier()).
71- Str("proxy_id", payload.ProxyID).
72- Msg("cef: popup-close bridge could not resolve source webview")
73- return
74- }
75- wv.handleSyntheticPopupClose(payload.ProxyID)
76 }77 }
M internal/infrastructure/cef/renderer_bridge.go +15 -25
@@ -499,46 +499,36 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
499499 return req, nil
500500 }
501501
502-func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
503- var req rendererBridgePopupOpenPayload
502+func decodeRendererBridgePopupPayload[T any](payload []byte, proxyID func(T) string) (T, error) {
503+ var req T
504504 if len(payload) == 0 {
505505 return req, fmt.Errorf("empty payload")
506506 }
507507 if err := json.Unmarshal(payload, &req); err != nil {
508508 return req, err
509509 }
510- if req.ProxyID == "" {
510+ if proxyID(req) == "" {
511511 return req, fmt.Errorf("missing proxy_id")
512512 }
513513 return req, nil
514514 }
515515
516+func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
517+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupOpenPayload) string {
518+ return req.ProxyID
519+ })
520+}
521+
516522 func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {
517- var req rendererBridgePopupNavigatePayload
518- if len(payload) == 0 {
519- return req, fmt.Errorf("empty payload")
520- }
521- if err := json.Unmarshal(payload, &req); err != nil {
522- return req, err
523- }
524- if req.ProxyID == "" {
525- return req, fmt.Errorf("missing proxy_id")
526- }
527- return req, nil
523+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupNavigatePayload) string {
524+ return req.ProxyID
525+ })
528526 }
529527
530528 func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {
531- var req rendererBridgePopupClosePayload
532- if len(payload) == 0 {
533- return req, fmt.Errorf("empty payload")
534- }
535- if err := json.Unmarshal(payload, &req); err != nil {
536- return req, err
537- }
538- if req.ProxyID == "" {
539- return req, fmt.Errorf("missing proxy_id")
540- }
541- return req, nil
529+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupClosePayload) string {
530+ return req.ProxyID
531+ })
542532 }
543533
544534 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
@@ -499,46 +499,36 @@ func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridge
499 return req, nil499 return req, nil
500 }500 }
501 501
502-func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {502+func decodeRendererBridgePopupPayload[T any](payload []byte, proxyID func(T) string) (T, error) {
503- var req rendererBridgePopupOpenPayload503+ var req T
504 if len(payload) == 0 {504 if len(payload) == 0 {
505 return req, fmt.Errorf("empty payload")505 return req, fmt.Errorf("empty payload")
506 }506 }
507 if err := json.Unmarshal(payload, &req); err != nil {507 if err := json.Unmarshal(payload, &req); err != nil {
508 return req, err508 return req, err
509 }509 }
510- if req.ProxyID == "" {510+ if proxyID(req) == "" {
511 return req, fmt.Errorf("missing proxy_id")511 return req, fmt.Errorf("missing proxy_id")
512 }512 }
513 return req, nil513 return req, nil
514 }514 }
515 515
516+func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) {
517+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupOpenPayload) string {
518+ return req.ProxyID
519+ })
520+}
521+
516 func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {522 func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) {
517- var req rendererBridgePopupNavigatePayload523+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupNavigatePayload) string {
518- if len(payload) == 0 {524+ return req.ProxyID
519- return req, fmt.Errorf("empty payload")525+ })
520- }
521- if err := json.Unmarshal(payload, &req); err != nil {
522- return req, err
523- }
524- if req.ProxyID == "" {
525- return req, fmt.Errorf("missing proxy_id")
526- }
527- return req, nil
528 }526 }
529 527
530 func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {528 func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) {
531- var req rendererBridgePopupClosePayload529+ return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupClosePayload) string {
532- if len(payload) == 0 {530+ return req.ProxyID
533- return req, fmt.Errorf("empty payload")531+ })
534- }
535- if err := json.Unmarshal(payload, &req); err != nil {
536- return req, err
537- }
538- if req.ProxyID == "" {
539- return req, fmt.Errorf("missing proxy_id")
540- }
541- return req, nil
542 }532 }
543 533
544 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {534 func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
M internal/infrastructure/cef/runtime_config.go +1 -1
@@ -9,7 +9,7 @@ import (
99
1010 var (
1111 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12- ErrRelatedWebViewUnsupported = errors.New("cef: related popup webviews are not supported yet")
12+ ErrRelatedWebViewUnsupported = port.ErrRelatedWebViewUnsupported
1313 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
1414 )
1515
@@ -9,7 +9,7 @@ import (
9 9
10 var (10 var (
11 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")11 ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12- ErrRelatedWebViewUnsupported = errors.New("cef: related popup webviews are not supported yet")12+ ErrRelatedWebViewUnsupported = port.ErrRelatedWebViewUnsupported
13 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")13 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
14 )14 )
15 15
M internal/infrastructure/cef/scheme_handler.go +20 -51
@@ -348,79 +348,48 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
348348 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
349349 }
350350
351-func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
351+func handlePopupBridgeRequest[T any](
352+ h *dumbSchemeHandler,
353+ request purecef.Request,
354+ browser purecef.Browser,
355+ action string,
356+ decode func([]byte) (T, error),
357+ dispatch func(purecef.Browser, T),
358+) purecef.ResourceHandler {
352359 if browser == nil {
353- h.logger.Debug().Msg("cef: popup-open — browser unavailable")
360+ h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
354361 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
355362 }
356363 if !h.hasTrustedBridgeNonce(request, browser) {
357- h.logger.Warn().Msg("cef: popup-open — rejected request without valid bridge nonce")
364+ h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
358365 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
359366 }
360367 body := readBodyFromHeader(request)
361368 if body == nil {
362369 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
363370 }
364- payload, err := decodeRendererBridgePopupOpenPayload(body)
371+ payload, err := decode(body)
365372 if err != nil {
366373 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
367374 }
368- if h.onPopupOpen != nil {
369- h.onPopupOpen(browser, payload)
375+ if dispatch != nil {
376+ dispatch(browser, payload)
370377 } else {
371- h.logger.Warn().Msg("cef: popup-open — callback not wired")
378+ h.logger.Warn().Msg("cef: " + action + " — callback not wired")
372379 }
373380 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
374381 }
375382
383+func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
384+ return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
385+}
386+
376387 func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
377- if browser == nil {
378- h.logger.Debug().Msg("cef: popup-navigate — browser unavailable")
379- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
380- }
381- if !h.hasTrustedBridgeNonce(request, browser) {
382- h.logger.Warn().Msg("cef: popup-navigate — rejected request without valid bridge nonce")
383- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
384- }
385- body := readBodyFromHeader(request)
386- if body == nil {
387- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
388- }
389- payload, err := decodeRendererBridgePopupNavigatePayload(body)
390- if err != nil {
391- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
392- }
393- if h.onPopupNavigate != nil {
394- h.onPopupNavigate(browser, payload)
395- } else {
396- h.logger.Warn().Msg("cef: popup-navigate — callback not wired")
397- }
398- return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
388+ return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
399389 }
400390
401391 func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
402- if browser == nil {
403- h.logger.Debug().Msg("cef: popup-close — browser unavailable")
404- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
405- }
406- if !h.hasTrustedBridgeNonce(request, browser) {
407- h.logger.Warn().Msg("cef: popup-close — rejected request without valid bridge nonce")
408- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
409- }
410- body := readBodyFromHeader(request)
411- if body == nil {
412- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
413- }
414- payload, err := decodeRendererBridgePopupClosePayload(body)
415- if err != nil {
416- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
417- }
418- if h.onPopupClose != nil {
419- h.onPopupClose(browser, payload)
420- } else {
421- h.logger.Warn().Msg("cef: popup-close — callback not wired")
422- }
423- return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
392+ return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
424393 }
425394
426395 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
@@ -348,79 +348,48 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
348 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})348 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
349 }349 }
350 350
351-func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {351+func handlePopupBridgeRequest[T any](
352+ h *dumbSchemeHandler,
353+ request purecef.Request,
354+ browser purecef.Browser,
355+ action string,
356+ decode func([]byte) (T, error),
357+ dispatch func(purecef.Browser, T),
358+) purecef.ResourceHandler {
352 if browser == nil {359 if browser == nil {
353- h.logger.Debug().Msg("cef: popup-open — browser unavailable")360+ h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
354 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})361 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
355 }362 }
356 if !h.hasTrustedBridgeNonce(request, browser) {363 if !h.hasTrustedBridgeNonce(request, browser) {
357- h.logger.Warn().Msg("cef: popup-open — rejected request without valid bridge nonce")364+ h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
358 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})365 return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
359 }366 }
360 body := readBodyFromHeader(request)367 body := readBodyFromHeader(request)
361 if body == nil {368 if body == nil {
362 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})369 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
363 }370 }
364- payload, err := decodeRendererBridgePopupOpenPayload(body)371+ payload, err := decode(body)
365 if err != nil {372 if err != nil {
366 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})373 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
367 }374 }
368- if h.onPopupOpen != nil {375+ if dispatch != nil {
369- h.onPopupOpen(browser, payload)376+ dispatch(browser, payload)
370 } else {377 } else {
371- h.logger.Warn().Msg("cef: popup-open — callback not wired")378+ h.logger.Warn().Msg("cef: " + action + " — callback not wired")
372 }379 }
373 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})380 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
374 }381 }
375 382
383+func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
384+ return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
385+}
386+
376 func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {387 func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
377- if browser == nil {388+ return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
378- h.logger.Debug().Msg("cef: popup-navigate — browser unavailable")
379- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
380- }
381- if !h.hasTrustedBridgeNonce(request, browser) {
382- h.logger.Warn().Msg("cef: popup-navigate — rejected request without valid bridge nonce")
383- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
384- }
385- body := readBodyFromHeader(request)
386- if body == nil {
387- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
388- }
389- payload, err := decodeRendererBridgePopupNavigatePayload(body)
390- if err != nil {
391- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
392- }
393- if h.onPopupNavigate != nil {
394- h.onPopupNavigate(browser, payload)
395- } else {
396- h.logger.Warn().Msg("cef: popup-navigate — callback not wired")
397- }
398- return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
399 }389 }
400 390
401 func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {391 func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
402- if browser == nil {392+ return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
403- h.logger.Debug().Msg("cef: popup-close — browser unavailable")
404- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
405- }
406- if !h.hasTrustedBridgeNonce(request, browser) {
407- h.logger.Warn().Msg("cef: popup-close — rejected request without valid bridge nonce")
408- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
409- }
410- body := readBodyFromHeader(request)
411- if body == nil {
412- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
413- }
414- payload, err := decodeRendererBridgePopupClosePayload(body)
415- if err != nil {
416- return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
417- }
418- if h.onPopupClose != nil {
419- h.onPopupClose(browser, payload)
420- } else {
421- h.logger.Warn().Msg("cef: popup-close — callback not wired")
422- }
423- return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
424 }393 }
425 394
426 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {395 func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
M internal/infrastructure/cef/webview_handlers.go +7 -2
@@ -730,6 +730,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
730730 if h.wv.engine != nil {
731731 h.wv.engine.recordBrowserAfterCreated(browser)
732732 h.wv.engine.registerWebView(h.wv)
733+ h.wv.engine.bindBrowserWebView(browser, h.wv)
733734 }
734735 host := browser.GetHost()
735736 if host == nil {
@@ -783,9 +784,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
783784 }
784785
785786 // OnBeforeClose fires the OnClose callback.
786-func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
787+func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
787788 if h.wv.engine != nil {
788- h.wv.engine.unregisterWebView(h.wv)
789+ browserID := int32(0)
790+ if browser != nil {
791+ browserID = browser.GetIdentifier()
792+ }
793+ h.wv.engine.unregisterWebView(h.wv, browserID)
789794 }
790795 h.wv.mu.Lock()
791796 h.wv.browser = nil
@@ -730,6 +730,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
730 if h.wv.engine != nil {730 if h.wv.engine != nil {
731 h.wv.engine.recordBrowserAfterCreated(browser)731 h.wv.engine.recordBrowserAfterCreated(browser)
732 h.wv.engine.registerWebView(h.wv)732 h.wv.engine.registerWebView(h.wv)
733+ h.wv.engine.bindBrowserWebView(browser, h.wv)
733 }734 }
734 host := browser.GetHost()735 host := browser.GetHost()
735 if host == nil {736 if host == nil {
@@ -783,9 +784,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
783 }784 }
784 785
785 // OnBeforeClose fires the OnClose callback.786 // OnBeforeClose fires the OnClose callback.
786-func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {787+func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
787 if h.wv.engine != nil {788 if h.wv.engine != nil {
788- h.wv.engine.unregisterWebView(h.wv)789+ browserID := int32(0)
790+ if browser != nil {
791+ browserID = browser.GetIdentifier()
792+ }
793+ h.wv.engine.unregisterWebView(h.wv, browserID)
789 }794 }
790 h.wv.mu.Lock()795 h.wv.mu.Lock()
791 h.wv.browser = nil796 h.wv.browser = nil
M internal/infrastructure/cef/webview_popup_proxy.go +23 -1
@@ -33,12 +33,32 @@ func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
3333 return state
3434 }
3535
36+func (wv *WebView) lookupSyntheticPopupState(proxyID string) *syntheticPopupState {
37+ if wv == nil || proxyID == "" {
38+ return nil
39+ }
40+
41+ wv.syntheticPopupMu.Lock()
42+ defer wv.syntheticPopupMu.Unlock()
43+ return wv.syntheticPopups[proxyID]
44+}
45+
46+func (wv *WebView) deleteSyntheticPopupState(proxyID string) {
47+ if wv == nil || proxyID == "" {
48+ return
49+ }
50+
51+ wv.syntheticPopupMu.Lock()
52+ defer wv.syntheticPopupMu.Unlock()
53+ delete(wv.syntheticPopups, proxyID)
54+}
55+
3656 func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {
3757 if wv == nil || proxyID == "" || wv.destroyed.Load() {
3858 return
3959 }
4060
41- state := wv.syntheticPopupState(proxyID)
61+ state := wv.lookupSyntheticPopupState(proxyID)
4262 if state != nil {
4363 wv.syntheticPopupMu.Lock()
4464 state.NoJavaScriptAccess = noJavaScriptAccess
@@ -54,6 +74,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
5474 cb := wv.callbacks
5575 wv.mu.RUnlock()
5676 if cb == nil || cb.OnCreate == nil {
77+ wv.deleteSyntheticPopupState(proxyID)
5778 return
5879 }
5980
@@ -65,6 +86,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
6586 ParentViewID: wv.id,
6687 })
6788 if popupWV == nil {
89+ wv.deleteSyntheticPopupState(proxyID)
6890 return
6991 }
7092
@@ -33,12 +33,32 @@ func (wv *WebView) syntheticPopupState(proxyID string) *syntheticPopupState {
33 return state33 return state
34 }34 }
35 35
36+func (wv *WebView) lookupSyntheticPopupState(proxyID string) *syntheticPopupState {
37+ if wv == nil || proxyID == "" {
38+ return nil
39+ }
40+
41+ wv.syntheticPopupMu.Lock()
42+ defer wv.syntheticPopupMu.Unlock()
43+ return wv.syntheticPopups[proxyID]
44+}
45+
46+func (wv *WebView) deleteSyntheticPopupState(proxyID string) {
47+ if wv == nil || proxyID == "" {
48+ return
49+ }
50+
51+ wv.syntheticPopupMu.Lock()
52+ defer wv.syntheticPopupMu.Unlock()
53+ delete(wv.syntheticPopups, proxyID)
54+}
55+
36 func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {56 func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) {
37 if wv == nil || proxyID == "" || wv.destroyed.Load() {57 if wv == nil || proxyID == "" || wv.destroyed.Load() {
38 return58 return
39 }59 }
40 60
41- state := wv.syntheticPopupState(proxyID)61+ state := wv.lookupSyntheticPopupState(proxyID)
42 if state != nil {62 if state != nil {
43 wv.syntheticPopupMu.Lock()63 wv.syntheticPopupMu.Lock()
44 state.NoJavaScriptAccess = noJavaScriptAccess64 state.NoJavaScriptAccess = noJavaScriptAccess
@@ -54,6 +74,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
54 cb := wv.callbacks74 cb := wv.callbacks
55 wv.mu.RUnlock()75 wv.mu.RUnlock()
56 if cb == nil || cb.OnCreate == nil {76 if cb == nil || cb.OnCreate == nil {
77+ wv.deleteSyntheticPopupState(proxyID)
57 return78 return
58 }79 }
59 80
@@ -65,6 +86,7 @@ func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string
65 ParentViewID: wv.id,86 ParentViewID: wv.id,
66 })87 })
67 if popupWV == nil {88 if popupWV == nil {
89+ wv.deleteSyntheticPopupState(proxyID)
68 return90 return
69 }91 }
70 92
M internal/infrastructure/cef/webview_popup_proxy_test.go +17 -0
@@ -66,6 +66,23 @@ func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
6666 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
6767 }
6868
69+func TestHandleSyntheticPopupOpen_CleansUpStateWhenPopupIsBlocked(t *testing.T) {
70+ parentWV := &WebView{
71+ ctx: context.Background(),
72+ id: port.WebViewID(28),
73+ callbacks: &port.WebViewCallbacks{
74+ OnCreate: func(port.PopupRequest) port.WebView {
75+ return nil
76+ },
77+ },
78+ }
79+
80+ parentWV.handleSyntheticPopupNavigate("popup-blocked", "https://example.com/blocked")
81+ parentWV.handleSyntheticPopupOpen("https://example.com/blocked", "_blank", "popup-blocked", true, false)
82+
83+ require.Empty(t, parentWV.syntheticPopups)
84+}
85+
6986 func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {
7087 popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
7188 parentWV := &WebView{
@@ -66,6 +66,23 @@ func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) {
66 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")66 parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish")
67 }67 }
68 68
69+func TestHandleSyntheticPopupOpen_CleansUpStateWhenPopupIsBlocked(t *testing.T) {
70+ parentWV := &WebView{
71+ ctx: context.Background(),
72+ id: port.WebViewID(28),
73+ callbacks: &port.WebViewCallbacks{
74+ OnCreate: func(port.PopupRequest) port.WebView {
75+ return nil
76+ },
77+ },
78+ }
79+
80+ parentWV.handleSyntheticPopupNavigate("popup-blocked", "https://example.com/blocked")
81+ parentWV.handleSyntheticPopupOpen("https://example.com/blocked", "_blank", "popup-blocked", true, false)
82+
83+ require.Empty(t, parentWV.syntheticPopups)
84+}
85+
69 func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {86 func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) {
70 popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}87 popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
71 parentWV := &WebView{88 parentWV := &WebView{
M internal/ui/coordinator/content/coordinator.go +9 -7
@@ -82,13 +82,15 @@ type Coordinator struct {
8282 gestureActionHandler input.ActionHandler
8383
8484 // Popup handling
85- factory port.WebViewFactory
86- popupConfig *entity.PopupBehaviorConfig
87- pendingPopups map[port.WebViewID]*PendingPopup
88- namedPopups map[namedPopupKey]*namedPopupState
89- popupOAuth map[port.WebViewID]*popupOAuthState
90- popupRefresh map[entity.PaneID]*time.Timer
91- popupMu sync.RWMutex
85+ factory port.WebViewFactory
86+ popupConfig *entity.PopupBehaviorConfig
87+ pendingPopups map[port.WebViewID]*PendingPopup
88+ namedPopups map[namedPopupKey]*namedPopupState
89+ popupOAuth map[port.WebViewID]*popupOAuthState
90+ popupRefresh map[entity.PaneID]*time.Timer
91+ relatedPopupUnsupported bool
92+ relatedPopupSupportDetected bool
93+ popupMu sync.RWMutex
9294
9395 // Callback to insert popup into workspace (avoids circular dependency)
9496 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
@@ -82,13 +82,15 @@ type Coordinator struct {
82 gestureActionHandler input.ActionHandler82 gestureActionHandler input.ActionHandler
83 83
84 // Popup handling84 // Popup handling
85- factory port.WebViewFactory85+ factory port.WebViewFactory
86- popupConfig *entity.PopupBehaviorConfig86+ popupConfig *entity.PopupBehaviorConfig
87- pendingPopups map[port.WebViewID]*PendingPopup87+ pendingPopups map[port.WebViewID]*PendingPopup
88- namedPopups map[namedPopupKey]*namedPopupState88+ namedPopups map[namedPopupKey]*namedPopupState
89- popupOAuth map[port.WebViewID]*popupOAuthState89+ popupOAuth map[port.WebViewID]*popupOAuthState
90- popupRefresh map[entity.PaneID]*time.Timer90+ popupRefresh map[entity.PaneID]*time.Timer
91- popupMu sync.RWMutex91+ relatedPopupUnsupported bool
92+ relatedPopupSupportDetected bool
93+ popupMu sync.RWMutex
92 94
93 // Callback to insert popup into workspace (avoids circular dependency)95 // Callback to insert popup into workspace (avoids circular dependency)
94 onInsertPopup func(ctx context.Context, input InsertPopupInput) error96 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
M internal/ui/coordinator/content/popup.go +37 -2
@@ -2,6 +2,7 @@ package content
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 neturl "net/url"
78 "strings"
@@ -218,6 +219,11 @@ func (c *Coordinator) SetPopupConfig(
218219 c.factory = factory
219220 c.popupConfig = popupConfig
220221 c.generateID = generateID
222+
223+ c.popupMu.Lock()
224+ c.relatedPopupUnsupported = false
225+ c.relatedPopupSupportDetected = false
226+ c.popupMu.Unlock()
221227 }
222228
223229 // SetOnInsertPopup sets the callback to insert popups into the workspace.
@@ -303,6 +309,26 @@ func normalizePopupTargetURIForFallback(rawTargetURI string) string {
303309 return redirectParsed.String()
304310 }
305311
312+func (c *Coordinator) relatedPopupSupportDisabled() bool {
313+ c.popupMu.RLock()
314+ defer c.popupMu.RUnlock()
315+ return c.relatedPopupSupportDetected && c.relatedPopupUnsupported
316+}
317+
318+func (c *Coordinator) markRelatedPopupUnsupported() {
319+ c.popupMu.Lock()
320+ defer c.popupMu.Unlock()
321+ c.relatedPopupSupportDetected = true
322+ c.relatedPopupUnsupported = true
323+}
324+
325+func (c *Coordinator) markRelatedPopupSupported() {
326+ c.popupMu.Lock()
327+ defer c.popupMu.Unlock()
328+ c.relatedPopupSupportDetected = true
329+ c.relatedPopupUnsupported = false
330+}
331+
306332 // createPopupWebView prefers a related WebView so popups can share the
307333 // parent session/context. If the engine does not support related popup views,
308334 // it gracefully falls back to a regular WebView so target="_blank" and
@@ -320,26 +346,35 @@ func (c *Coordinator) createPopupWebView(
320346 log := logging.FromContext(ctx)
321347 relatedErr := error(nil)
322348
323- if !noJavaScriptAccess {
349+ if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
324350 popupWV, err := c.factory.CreateRelated(ctx, parentID)
325351 if err == nil && popupWV != nil {
352+ c.markRelatedPopupSupported()
326353 return popupWV, targetURI, nil
327354 }
328355 if err == nil {
329356 err = fmt.Errorf("related popup webview factory returned nil without error")
330357 }
331358 relatedErr = err
359+ if errors.Is(err, port.ErrRelatedWebViewUnsupported) {
360+ c.markRelatedPopupUnsupported()
361+ }
332362
333363 log.Debug().
334364 Err(err).
335365 Uint64("parent_webview_id", uint64(parentID)).
336366 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
337367 Msg("related popup webview unavailable, falling back to regular webview")
338- } else {
368+ } else if noJavaScriptAccess {
339369 log.Debug().
340370 Uint64("parent_webview_id", uint64(parentID)).
341371 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
342372 Msg("popup requested no JavaScript opener access, creating regular webview")
373+ } else {
374+ log.Debug().
375+ Uint64("parent_webview_id", uint64(parentID)).
376+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
377+ Msg("related popup webviews known unsupported, creating regular webview")
343378 }
344379
345380 popupWV, fallbackErr := c.factory.Create(ctx)
@@ -2,6 +2,7 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 neturl "net/url"7 neturl "net/url"
7 "strings"8 "strings"
@@ -218,6 +219,11 @@ func (c *Coordinator) SetPopupConfig(
218 c.factory = factory219 c.factory = factory
219 c.popupConfig = popupConfig220 c.popupConfig = popupConfig
220 c.generateID = generateID221 c.generateID = generateID
222+
223+ c.popupMu.Lock()
224+ c.relatedPopupUnsupported = false
225+ c.relatedPopupSupportDetected = false
226+ c.popupMu.Unlock()
221 }227 }
222 228
223 // SetOnInsertPopup sets the callback to insert popups into the workspace.229 // SetOnInsertPopup sets the callback to insert popups into the workspace.
@@ -303,6 +309,26 @@ func normalizePopupTargetURIForFallback(rawTargetURI string) string {
303 return redirectParsed.String()309 return redirectParsed.String()
304 }310 }
305 311
312+func (c *Coordinator) relatedPopupSupportDisabled() bool {
313+ c.popupMu.RLock()
314+ defer c.popupMu.RUnlock()
315+ return c.relatedPopupSupportDetected && c.relatedPopupUnsupported
316+}
317+
318+func (c *Coordinator) markRelatedPopupUnsupported() {
319+ c.popupMu.Lock()
320+ defer c.popupMu.Unlock()
321+ c.relatedPopupSupportDetected = true
322+ c.relatedPopupUnsupported = true
323+}
324+
325+func (c *Coordinator) markRelatedPopupSupported() {
326+ c.popupMu.Lock()
327+ defer c.popupMu.Unlock()
328+ c.relatedPopupSupportDetected = true
329+ c.relatedPopupUnsupported = false
330+}
331+
306 // createPopupWebView prefers a related WebView so popups can share the332 // createPopupWebView prefers a related WebView so popups can share the
307 // parent session/context. If the engine does not support related popup views,333 // parent session/context. If the engine does not support related popup views,
308 // it gracefully falls back to a regular WebView so target="_blank" and334 // it gracefully falls back to a regular WebView so target="_blank" and
@@ -320,26 +346,35 @@ func (c *Coordinator) createPopupWebView(
320 log := logging.FromContext(ctx)346 log := logging.FromContext(ctx)
321 relatedErr := error(nil)347 relatedErr := error(nil)
322 348
323- if !noJavaScriptAccess {349+ if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
324 popupWV, err := c.factory.CreateRelated(ctx, parentID)350 popupWV, err := c.factory.CreateRelated(ctx, parentID)
325 if err == nil && popupWV != nil {351 if err == nil && popupWV != nil {
352+ c.markRelatedPopupSupported()
326 return popupWV, targetURI, nil353 return popupWV, targetURI, nil
327 }354 }
328 if err == nil {355 if err == nil {
329 err = fmt.Errorf("related popup webview factory returned nil without error")356 err = fmt.Errorf("related popup webview factory returned nil without error")
330 }357 }
331 relatedErr = err358 relatedErr = err
359+ if errors.Is(err, port.ErrRelatedWebViewUnsupported) {
360+ c.markRelatedPopupUnsupported()
361+ }
332 362
333 log.Debug().363 log.Debug().
334 Err(err).364 Err(err).
335 Uint64("parent_webview_id", uint64(parentID)).365 Uint64("parent_webview_id", uint64(parentID)).
336 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).366 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
337 Msg("related popup webview unavailable, falling back to regular webview")367 Msg("related popup webview unavailable, falling back to regular webview")
338- } else {368+ } else if noJavaScriptAccess {
339 log.Debug().369 log.Debug().
340 Uint64("parent_webview_id", uint64(parentID)).370 Uint64("parent_webview_id", uint64(parentID)).
341 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).371 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
342 Msg("popup requested no JavaScript opener access, creating regular webview")372 Msg("popup requested no JavaScript opener access, creating regular webview")
373+ } else {
374+ log.Debug().
375+ Uint64("parent_webview_id", uint64(parentID)).
376+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
377+ Msg("related popup webviews known unsupported, creating regular webview")
343 }378 }
344 379
345 popupWV, fallbackErr := c.factory.Create(ctx)380 popupWV, fallbackErr := c.factory.Create(ctx)
M internal/ui/coordinator/content/popup_test.go +52 -0
@@ -181,6 +181,58 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
181181 require.Same(t, popupWV, created)
182182 }
183183
184+func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *testing.T) {
185+ ctx := context.Background()
186+ parentPaneID := entity.PaneID("parent-pane")
187+ parentWV := mocks.NewMockWebView(t)
188+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
189+
190+ firstPopupWV := mocks.NewMockWebView(t)
191+ firstPopupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
192+ firstPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
193+ firstPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
194+ firstPopupWV.EXPECT().IsLoading().Return(false).Once()
195+ firstPopupWV.EXPECT().URI().Return("").Once()
196+ firstPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Once()
197+
198+ secondPopupWV := mocks.NewMockWebView(t)
199+ secondPopupWV.EXPECT().ID().Return(port.WebViewID(203)).Once()
200+ secondPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
201+ secondPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
202+ secondPopupWV.EXPECT().IsLoading().Return(false).Once()
203+ secondPopupWV.EXPECT().URI().Return("").Once()
204+ secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
205+
206+ factory := mocks.NewMockWebViewFactory(t)
207+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, port.ErrRelatedWebViewUnsupported).Once()
208+ factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
209+ factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
210+
211+ c := &Coordinator{
212+ webViews: make(map[entity.PaneID]port.WebView),
213+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
214+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
215+ }
216+ c.SetPopupConfig(factory, nil, nil)
217+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
218+ return nil
219+ })
220+
221+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
222+ TargetURI: "https://example.com/first",
223+ FrameName: "_blank",
224+ IsUserGesture: true,
225+ })
226+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
227+ TargetURI: "https://example.com/second",
228+ FrameName: "_blank",
229+ IsUserGesture: true,
230+ })
231+
232+ require.Same(t, firstPopupWV, first)
233+ require.Same(t, secondPopupWV, second)
234+}
235+
184236 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
185237 ctx := context.Background()
186238 parentPaneID := entity.PaneID("parent-pane")
@@ -181,6 +181,58 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
181 require.Same(t, popupWV, created)181 require.Same(t, popupWV, created)
182 }182 }
183 183
184+func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *testing.T) {
185+ ctx := context.Background()
186+ parentPaneID := entity.PaneID("parent-pane")
187+ parentWV := mocks.NewMockWebView(t)
188+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
189+
190+ firstPopupWV := mocks.NewMockWebView(t)
191+ firstPopupWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
192+ firstPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
193+ firstPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
194+ firstPopupWV.EXPECT().IsLoading().Return(false).Once()
195+ firstPopupWV.EXPECT().URI().Return("").Once()
196+ firstPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Once()
197+
198+ secondPopupWV := mocks.NewMockWebView(t)
199+ secondPopupWV.EXPECT().ID().Return(port.WebViewID(203)).Once()
200+ secondPopupWV.EXPECT().Generation().Return(uint64(1)).Once()
201+ secondPopupWV.EXPECT().SetCallbacks(mock.Anything).Once()
202+ secondPopupWV.EXPECT().IsLoading().Return(false).Once()
203+ secondPopupWV.EXPECT().URI().Return("").Once()
204+ secondPopupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
205+
206+ factory := mocks.NewMockWebViewFactory(t)
207+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, port.ErrRelatedWebViewUnsupported).Once()
208+ factory.EXPECT().Create(mock.Anything).Return(firstPopupWV, nil).Once()
209+ factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
210+
211+ c := &Coordinator{
212+ webViews: make(map[entity.PaneID]port.WebView),
213+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
214+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
215+ }
216+ c.SetPopupConfig(factory, nil, nil)
217+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
218+ return nil
219+ })
220+
221+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
222+ TargetURI: "https://example.com/first",
223+ FrameName: "_blank",
224+ IsUserGesture: true,
225+ })
226+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
227+ TargetURI: "https://example.com/second",
228+ FrameName: "_blank",
229+ IsUserGesture: true,
230+ })
231+
232+ require.Same(t, firstPopupWV, first)
233+ require.Same(t, secondPopupWV, second)
234+}
235+
184 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {236 func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFails(t *testing.T) {
185 ctx := context.Background()237 ctx := context.Background()
186 parentPaneID := entity.PaneID("parent-pane")238 parentPaneID := entity.PaneID("parent-pane")

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