detached 82824ac

fix(engine): disarm popup lifecycle callbacks

commit
82824aca0c59776e63a70d50c15cc325e9382a85
author
brice · · 2026-07-25 08:26 UTC
committer
brice · · 2026-07-26 16:42 UTC
parent
9a38c58
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +107 -20
M internal/infrastructure/cef/webview.go +13 -2
@@ -172,6 +172,7 @@ type WebView struct {
172172 // Programmatic popup lifecycle callbacks used for OAuth auto-close and
173173 // synthetic window.open() proxy support on CEF.
174174 closeCallbacks []func()
175+ popupLifecycleClose func()
175176 navigationCallbacks []func(string)
176177 openerMessageCallbacks []func()
177178 openerNavigationCallbacks []func(string)
@@ -653,9 +654,15 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
653654 }
654655 }
655656
656-// SetOnClose implements port.PopupLifecycleCapable.
657+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
658+// disturbing OAuth and other additive close callbacks.
657659 func (wv *WebView) SetOnClose(fn func()) {
658- wv.AddCloseCallback(fn)
660+ if wv == nil {
661+ return
662+ }
663+ wv.mu.Lock()
664+ wv.popupLifecycleClose = fn
665+ wv.mu.Unlock()
659666 }
660667
661668 // Show implements port.PopupLifecycleCapable.
@@ -2299,6 +2306,10 @@ func (wv *WebView) runCloseCallbacks() {
22992306 wv.mu.Lock()
23002307 callbacks := append([]func(){}, wv.closeCallbacks...)
23012308 wv.closeCallbacks = nil
2309+ if wv.popupLifecycleClose != nil {
2310+ callbacks = append(callbacks, wv.popupLifecycleClose)
2311+ wv.popupLifecycleClose = nil
2312+ }
23022313 wv.mu.Unlock()
23032314 if len(callbacks) == 0 {
23042315 return
@@ -172,6 +172,7 @@ type WebView struct {
172 // Programmatic popup lifecycle callbacks used for OAuth auto-close and172 // Programmatic popup lifecycle callbacks used for OAuth auto-close and
173 // synthetic window.open() proxy support on CEF.173 // synthetic window.open() proxy support on CEF.
174 closeCallbacks []func()174 closeCallbacks []func()
175+ popupLifecycleClose func()
175 navigationCallbacks []func(string)176 navigationCallbacks []func(string)
176 openerMessageCallbacks []func()177 openerMessageCallbacks []func()
177 openerNavigationCallbacks []func(string)178 openerNavigationCallbacks []func(string)
@@ -653,9 +654,15 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
653 }654 }
654 }655 }
655 656
656-// SetOnClose implements port.PopupLifecycleCapable.657+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
658+// disturbing OAuth and other additive close callbacks.
657 func (wv *WebView) SetOnClose(fn func()) {659 func (wv *WebView) SetOnClose(fn func()) {
658- wv.AddCloseCallback(fn)660+ if wv == nil {
661+ return
662+ }
663+ wv.mu.Lock()
664+ wv.popupLifecycleClose = fn
665+ wv.mu.Unlock()
659 }666 }
660 667
661 // Show implements port.PopupLifecycleCapable.668 // Show implements port.PopupLifecycleCapable.
@@ -2299,6 +2306,10 @@ func (wv *WebView) runCloseCallbacks() {
2299 wv.mu.Lock()2306 wv.mu.Lock()
2300 callbacks := append([]func(){}, wv.closeCallbacks...)2307 callbacks := append([]func(){}, wv.closeCallbacks...)
2301 wv.closeCallbacks = nil2308 wv.closeCallbacks = nil
2309+ if wv.popupLifecycleClose != nil {
2310+ callbacks = append(callbacks, wv.popupLifecycleClose)
2311+ wv.popupLifecycleClose = nil
2312+ }
2302 wv.mu.Unlock()2313 wv.mu.Unlock()
2303 if len(callbacks) == 0 {2314 if len(callbacks) == 0 {
2304 return2315 return
A internal/infrastructure/cef/webview_popup_lifecycle_test.go +26 -0
@@ -0,0 +1,26 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
10+ wv := &WebView{inputAttached: true}
11+ readyCalls := 0
12+ lifecycleCloseCalls := 0
13+ oauthCloseCalls := 0
14+ wv.SetOnReadyToShow(func() { readyCalls++ })
15+ wv.SetOnClose(func() { lifecycleCloseCalls++ })
16+ wv.AddCloseCallback(func() { oauthCloseCalls++ })
17+
18+ wv.SetOnReadyToShow(nil)
19+ wv.SetOnClose(nil)
20+ wv.fireReadyToShow()
21+ wv.runCloseCallbacks()
22+
23+ assert.Zero(t, readyCalls)
24+ assert.Zero(t, lifecycleCloseCalls)
25+ assert.Equal(t, 1, oauthCloseCalls)
26+}
@@ -0,0 +1,26 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
10+ wv := &WebView{inputAttached: true}
11+ readyCalls := 0
12+ lifecycleCloseCalls := 0
13+ oauthCloseCalls := 0
14+ wv.SetOnReadyToShow(func() { readyCalls++ })
15+ wv.SetOnClose(func() { lifecycleCloseCalls++ })
16+ wv.AddCloseCallback(func() { oauthCloseCalls++ })
17+
18+ wv.SetOnReadyToShow(nil)
19+ wv.SetOnClose(nil)
20+ wv.fireReadyToShow()
21+ wv.runCloseCallbacks()
22+
23+ assert.Zero(t, readyCalls)
24+ assert.Zero(t, lifecycleCloseCalls)
25+ assert.Equal(t, 1, oauthCloseCalls)
26+}
M internal/infrastructure/webkit/webview.go +42 -18
@@ -155,6 +155,7 @@ type WebView struct {
155155 OnProgressChanged func(float64)
156156 OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
157157 OnClose func()
158+ popupLifecycleClose func()
158159 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
159160 OnReadyToShow func() // Called when popup is ready to display
160161 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
@@ -515,9 +516,7 @@ func (wv *WebView) connectWebProcessResponsiveSignal() {
515516
516517 func (wv *WebView) connectCloseSignal() {
517518 closeCb := func(_ webkit.WebView) {
518- if wv.OnClose != nil {
519- wv.OnClose()
520- }
519+ wv.runCloseCallbacks()
521520 }
522521 sigID := wv.inner.ConnectClose(&closeCb)
523522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
@@ -633,9 +632,7 @@ func (wv *WebView) connectCreateSignal() {
633632
634633 func (wv *WebView) connectReadyToShowSignal() {
635634 readyToShowCb := func(_ webkit.WebView) {
636- if wv.OnReadyToShow != nil {
637- wv.OnReadyToShow()
638- }
635+ wv.fireReadyToShow()
639636 }
640637 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
641638 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
@@ -1663,27 +1660,54 @@ func (*WebView) PrimePopupNavigation(string) {}
16631660 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
16641661 // It implements port.PopupLifecycleCapable.
16651662 func (wv *WebView) SetOnReadyToShow(fn func()) {
1663+ if wv == nil {
1664+ return
1665+ }
1666+ wv.mu.Lock()
16661667 wv.OnReadyToShow = fn
1668+ wv.mu.Unlock()
16671669 }
16681670
1669-// SetOnClose composes fn with any existing OnClose handler so multiple callers
1670-// can each register a close callback without overwriting one another.
1671-// It implements port.PopupLifecycleCapable.
1671+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
1672+// disturbing WebView callbacks or additive OAuth close callbacks.
16721673 func (wv *WebView) SetOnClose(fn func()) {
1673- existing := wv.OnClose
1674- if existing == nil {
1675- wv.OnClose = fn
1674+ if wv == nil {
16761675 return
16771676 }
1678- if fn == nil {
1677+ wv.mu.Lock()
1678+ wv.popupLifecycleClose = fn
1679+ wv.mu.Unlock()
1680+}
1681+
1682+func (wv *WebView) fireReadyToShow() {
1683+ if wv == nil {
16791684 return
16801685 }
1681- wv.OnClose = func() {
1682- existing()
1686+ wv.mu.RLock()
1687+ fn := wv.OnReadyToShow
1688+ wv.mu.RUnlock()
1689+ if fn != nil {
16831690 fn()
16841691 }
16851692 }
16861693
1694+func (wv *WebView) runCloseCallbacks() {
1695+ if wv == nil {
1696+ return
1697+ }
1698+ wv.mu.Lock()
1699+ base := wv.OnClose
1700+ lifecycle := wv.popupLifecycleClose
1701+ wv.popupLifecycleClose = nil
1702+ wv.mu.Unlock()
1703+ if base != nil {
1704+ base()
1705+ }
1706+ if lifecycle != nil {
1707+ lifecycle()
1708+ }
1709+}
1710+
16871711 // State returns the current WebView state as a snapshot.
16881712 func (wv *WebView) State() port.WebViewState {
16891713 return port.WebViewState{
@@ -1842,9 +1866,7 @@ func (wv *WebView) Close() {
18421866 if wv.destroyed.Load() {
18431867 return
18441868 }
1845- if wv.OnClose != nil {
1846- wv.OnClose()
1847- }
1869+ wv.runCloseCallbacks()
18481870 }
18491871
18501872 // AddCloseCallback implements port.OAuthCallbackCapable.
@@ -1980,6 +2002,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
19802002 wv.browsingContextDecision = dto.HostDecision{}
19812003 wv.hasBrowsingContextDecision = false
19822004 wv.nativePopupHostAbort = nil
2005+ wv.popupLifecycleClose = nil
19832006 wv.mu.Unlock()
19842007
19852008 // 4. Unparent from GTK hierarchy (must happen before process termination)
@@ -2050,6 +2073,7 @@ func (wv *WebView) ResetForPoolReuse() {
20502073 wv.browsingContextDecision = dto.HostDecision{}
20512074 wv.hasBrowsingContextDecision = false
20522075 wv.nativePopupHostAbort = nil
2076+ wv.popupLifecycleClose = nil
20532077 wv.lastProgressUpdate.Store(0)
20542078 wv.mu.Unlock()
20552079
@@ -155,6 +155,7 @@ type WebView struct {
155 OnProgressChanged func(float64)155 OnProgressChanged func(float64)
156 OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes156 OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
157 OnClose func()157 OnClose func()
158+ popupLifecycleClose func()
158 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup159 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
159 OnReadyToShow func() // Called when popup is ready to display160 OnReadyToShow func() // Called when popup is ready to display
160 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)161 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
@@ -515,9 +516,7 @@ func (wv *WebView) connectWebProcessResponsiveSignal() {
515 516
516 func (wv *WebView) connectCloseSignal() {517 func (wv *WebView) connectCloseSignal() {
517 closeCb := func(_ webkit.WebView) {518 closeCb := func(_ webkit.WebView) {
518- if wv.OnClose != nil {519+ wv.runCloseCallbacks()
519- wv.OnClose()
520- }
521 }520 }
522 sigID := wv.inner.ConnectClose(&closeCb)521 sigID := wv.inner.ConnectClose(&closeCb)
523 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))522 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
@@ -633,9 +632,7 @@ func (wv *WebView) connectCreateSignal() {
633 632
634 func (wv *WebView) connectReadyToShowSignal() {633 func (wv *WebView) connectReadyToShowSignal() {
635 readyToShowCb := func(_ webkit.WebView) {634 readyToShowCb := func(_ webkit.WebView) {
636- if wv.OnReadyToShow != nil {635+ wv.fireReadyToShow()
637- wv.OnReadyToShow()
638- }
639 }636 }
640 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)637 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
641 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))638 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
@@ -1663,27 +1660,54 @@ func (*WebView) PrimePopupNavigation(string) {}
1663 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.1660 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1664 // It implements port.PopupLifecycleCapable.1661 // It implements port.PopupLifecycleCapable.
1665 func (wv *WebView) SetOnReadyToShow(fn func()) {1662 func (wv *WebView) SetOnReadyToShow(fn func()) {
1663+ if wv == nil {
1664+ return
1665+ }
1666+ wv.mu.Lock()
1666 wv.OnReadyToShow = fn1667 wv.OnReadyToShow = fn
1668+ wv.mu.Unlock()
1667 }1669 }
1668 1670
1669-// SetOnClose composes fn with any existing OnClose handler so multiple callers1671+// SetOnClose replaces the coordinator-owned popup lifecycle callback without
1670-// can each register a close callback without overwriting one another.1672+// disturbing WebView callbacks or additive OAuth close callbacks.
1671-// It implements port.PopupLifecycleCapable.
1672 func (wv *WebView) SetOnClose(fn func()) {1673 func (wv *WebView) SetOnClose(fn func()) {
1673- existing := wv.OnClose1674+ if wv == nil {
1674- if existing == nil {
1675- wv.OnClose = fn
1676 return1675 return
1677 }1676 }
1678- if fn == nil {1677+ wv.mu.Lock()
1678+ wv.popupLifecycleClose = fn
1679+ wv.mu.Unlock()
1680+}
1681+
1682+func (wv *WebView) fireReadyToShow() {
1683+ if wv == nil {
1679 return1684 return
1680 }1685 }
1681- wv.OnClose = func() {1686+ wv.mu.RLock()
1682- existing()1687+ fn := wv.OnReadyToShow
1688+ wv.mu.RUnlock()
1689+ if fn != nil {
1683 fn()1690 fn()
1684 }1691 }
1685 }1692 }
1686 1693
1694+func (wv *WebView) runCloseCallbacks() {
1695+ if wv == nil {
1696+ return
1697+ }
1698+ wv.mu.Lock()
1699+ base := wv.OnClose
1700+ lifecycle := wv.popupLifecycleClose
1701+ wv.popupLifecycleClose = nil
1702+ wv.mu.Unlock()
1703+ if base != nil {
1704+ base()
1705+ }
1706+ if lifecycle != nil {
1707+ lifecycle()
1708+ }
1709+}
1710+
1687 // State returns the current WebView state as a snapshot.1711 // State returns the current WebView state as a snapshot.
1688 func (wv *WebView) State() port.WebViewState {1712 func (wv *WebView) State() port.WebViewState {
1689 return port.WebViewState{1713 return port.WebViewState{
@@ -1842,9 +1866,7 @@ func (wv *WebView) Close() {
1842 if wv.destroyed.Load() {1866 if wv.destroyed.Load() {
1843 return1867 return
1844 }1868 }
1845- if wv.OnClose != nil {1869+ wv.runCloseCallbacks()
1846- wv.OnClose()
1847- }
1848 }1870 }
1849 1871
1850 // AddCloseCallback implements port.OAuthCallbackCapable.1872 // AddCloseCallback implements port.OAuthCallbackCapable.
@@ -1980,6 +2002,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1980 wv.browsingContextDecision = dto.HostDecision{}2002 wv.browsingContextDecision = dto.HostDecision{}
1981 wv.hasBrowsingContextDecision = false2003 wv.hasBrowsingContextDecision = false
1982 wv.nativePopupHostAbort = nil2004 wv.nativePopupHostAbort = nil
2005+ wv.popupLifecycleClose = nil
1983 wv.mu.Unlock()2006 wv.mu.Unlock()
1984 2007
1985 // 4. Unparent from GTK hierarchy (must happen before process termination)2008 // 4. Unparent from GTK hierarchy (must happen before process termination)
@@ -2050,6 +2073,7 @@ func (wv *WebView) ResetForPoolReuse() {
2050 wv.browsingContextDecision = dto.HostDecision{}2073 wv.browsingContextDecision = dto.HostDecision{}
2051 wv.hasBrowsingContextDecision = false2074 wv.hasBrowsingContextDecision = false
2052 wv.nativePopupHostAbort = nil2075 wv.nativePopupHostAbort = nil
2076+ wv.popupLifecycleClose = nil
2053 wv.lastProgressUpdate.Store(0)2077 wv.lastProgressUpdate.Store(0)
2054 wv.mu.Unlock()2078 wv.mu.Unlock()
2055 2079
A internal/infrastructure/webkit/webview_popup_lifecycle_test.go +26 -0
@@ -0,0 +1,26 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
10+ wv := &WebView{}
11+ readyCalls := 0
12+ lifecycleCloseCalls := 0
13+ oauthCloseCalls := 0
14+ wv.SetOnReadyToShow(func() { readyCalls++ })
15+ wv.SetOnClose(func() { lifecycleCloseCalls++ })
16+ wv.AddCloseCallback(func() { oauthCloseCalls++ })
17+
18+ wv.SetOnReadyToShow(nil)
19+ wv.SetOnClose(nil)
20+ wv.fireReadyToShow()
21+ wv.runCloseCallbacks()
22+
23+ assert.Zero(t, readyCalls)
24+ assert.Zero(t, lifecycleCloseCalls)
25+ assert.Equal(t, 1, oauthCloseCalls)
26+}
@@ -0,0 +1,26 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
10+ wv := &WebView{}
11+ readyCalls := 0
12+ lifecycleCloseCalls := 0
13+ oauthCloseCalls := 0
14+ wv.SetOnReadyToShow(func() { readyCalls++ })
15+ wv.SetOnClose(func() { lifecycleCloseCalls++ })
16+ wv.AddCloseCallback(func() { oauthCloseCalls++ })
17+
18+ wv.SetOnReadyToShow(nil)
19+ wv.SetOnClose(nil)
20+ wv.fireReadyToShow()
21+ wv.runCloseCallbacks()
22+
23+ assert.Zero(t, readyCalls)
24+ assert.Zero(t, lifecycleCloseCalls)
25+ assert.Equal(t, 1, oauthCloseCalls)
26+}

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