detached 0d014a0

fix(cef): preserve committed systemview identity (#445)

* fix(cef): preserve committed systemview identity

* fix(cef): resolve internal favicons from committed URL
commit
0d014a0f98dadb74f8c6f834e576cbc13d400017
author
bnema · · 2026-09-22 05:43 UTC
committer
GitHub · · 2026-09-22 05:43 UTC
parent
df4f3ad
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +159 -21
M internal/infrastructure/cef/content_injector.go +5 -3
@@ -3,7 +3,6 @@ package cef
33 import (
44 "context"
55 "fmt"
6- "strings"
76 "sync"
87
98 "github.com/bnema/dumber/internal/infrastructure/webutil"
@@ -255,7 +254,10 @@ func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView)
255254 // OnLoadEnd callback's browser (not mutable WebView state), so events that
256255 // are provably stale are skipped: a replaced browser identifier (old main
257256 // frame during process swap) or a superseded intent ID (a newer navigation
258-// committed while the callback was queued). Ambiguous cases fall back to skipping rather than
257+// committed while the callback was queued). The raw committed CEF URL in the
258+// event is compared only against the raw committed URL as an additional
259+// stale-event guard; it is never compared against the conceptual URI.
260+// Ambiguous cases fall back to skipping rather than
259261 // installing blind: an event captured without a browser cannot prove
260262 // identity, and the current browser gets its own load-end installation.
261263 // The installation is pinned to the validated document sequence, so a
@@ -274,7 +276,7 @@ func (ci *contentInjector) onLoadEndForEvent(wv *WebView, event injectionEvent)
274276 }
275277 wv.mu.Lock()
276278 if wv.browser != browser || wv.pendingIntentID != event.intentID || event.documentSeq == 0 ||
277- wv.documentSeq != event.documentSeq || strings.TrimSpace(wv.uri) != event.frameURL ||
279+ wv.documentSeq != event.documentSeq || wv.committedURL != event.committedURL ||
278280 wv.installedDocumentSeq == event.documentSeq {
279281 wv.mu.Unlock()
280282 return
@@ -3,7 +3,6 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "strings"
7 "sync"6 "sync"
8 7
9 "github.com/bnema/dumber/internal/infrastructure/webutil"8 "github.com/bnema/dumber/internal/infrastructure/webutil"
@@ -255,7 +254,10 @@ func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView)
255 // OnLoadEnd callback's browser (not mutable WebView state), so events that254 // OnLoadEnd callback's browser (not mutable WebView state), so events that
256 // are provably stale are skipped: a replaced browser identifier (old main255 // are provably stale are skipped: a replaced browser identifier (old main
257 // frame during process swap) or a superseded intent ID (a newer navigation256 // frame during process swap) or a superseded intent ID (a newer navigation
258-// committed while the callback was queued). Ambiguous cases fall back to skipping rather than257+// committed while the callback was queued). The raw committed CEF URL in the
258+// event is compared only against the raw committed URL as an additional
259+// stale-event guard; it is never compared against the conceptual URI.
260+// Ambiguous cases fall back to skipping rather than
259 // installing blind: an event captured without a browser cannot prove261 // installing blind: an event captured without a browser cannot prove
260 // identity, and the current browser gets its own load-end installation.262 // identity, and the current browser gets its own load-end installation.
261 // The installation is pinned to the validated document sequence, so a263 // The installation is pinned to the validated document sequence, so a
@@ -274,7 +276,7 @@ func (ci *contentInjector) onLoadEndForEvent(wv *WebView, event injectionEvent)
274 }276 }
275 wv.mu.Lock()277 wv.mu.Lock()
276 if wv.browser != browser || wv.pendingIntentID != event.intentID || event.documentSeq == 0 ||278 if wv.browser != browser || wv.pendingIntentID != event.intentID || event.documentSeq == 0 ||
277- wv.documentSeq != event.documentSeq || strings.TrimSpace(wv.uri) != event.frameURL ||279+ wv.documentSeq != event.documentSeq || wv.committedURL != event.committedURL ||
278 wv.installedDocumentSeq == event.documentSeq {280 wv.installedDocumentSeq == event.documentSeq {
279 wv.mu.Unlock()281 wv.mu.Unlock()
280 return282 return
M internal/infrastructure/cef/favicon_discovery_test.go +46 -0
@@ -72,6 +72,26 @@ func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
7272 require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
7373 }
7474
75+func TestCEFOnFaviconUrlchangeResolvesInternalCandidatesAgainstCommittedURL(t *testing.T) {
76+ prevDecode := decodeCEFStringList
77+ decodeCEFStringList = func(purecef.StringList) []string { return []string{"/favicon.png"} }
78+ defer func() { decodeCEFStringList = prevDecode }()
79+
80+ var page string
81+ var icons []string
82+ browser := cefmocks.NewMockBrowser(t)
83+ browser.EXPECT().GetIdentifier().Return(int32(7)).Maybe()
84+ wv := &WebView{ctx: context.Background(), browser: browser, documentSeq: 1, callbacks: &port.WebViewCallbacks{
85+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
86+ }}
87+ wv.setCommittedURLsLocked("https://dumber.invalid/history", "dumb://history")
88+
89+ (&handlerSet{wv: wv}).OnFaviconUrlchange(browser, purecef.StringList(1))
90+
91+ require.Equal(t, "dumb://history", page)
92+ require.Equal(t, []string{"https://dumber.invalid/favicon.png"}, icons)
93+}
94+
7595 func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
7696 prevNewStringVisitor := cefNewStringVisitor
7797 cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
@@ -97,6 +117,32 @@ func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
97117 require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
98118 }
99119
120+func TestCEFLoadingFinishedResolvesInternalMetadataAgainstCommittedURL(t *testing.T) {
121+ prevNewStringVisitor := cefNewStringVisitor
122+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
123+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
124+
125+ browser := cefmocks.NewMockBrowser(t)
126+ frame := cefmocks.NewMockFrame(t)
127+ browser.EXPECT().GetIdentifier().Return(int32(7)).Maybe()
128+ browser.EXPECT().GetMainFrame().Return(frame)
129+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
130+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
131+ }).Return()
132+
133+ var page string
134+ var icons []string
135+ wv := &WebView{ctx: context.Background(), browser: browser, documentSeq: 1, callbacks: &port.WebViewCallbacks{
136+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
137+ }}
138+ wv.setCommittedURLsLocked("https://dumber.invalid/history", "dumb://history")
139+
140+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
141+
142+ require.Equal(t, "dumb://history", page)
143+ require.Equal(t, []string{"https://dumber.invalid/meta.png", "https://dumber.invalid/favicon.ico"}, icons)
144+}
145+
100146 func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
101147 var page string
102148 var icons []string
@@ -72,6 +72,26 @@ func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
72 require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)72 require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
73 }73 }
74 74
75+func TestCEFOnFaviconUrlchangeResolvesInternalCandidatesAgainstCommittedURL(t *testing.T) {
76+ prevDecode := decodeCEFStringList
77+ decodeCEFStringList = func(purecef.StringList) []string { return []string{"/favicon.png"} }
78+ defer func() { decodeCEFStringList = prevDecode }()
79+
80+ var page string
81+ var icons []string
82+ browser := cefmocks.NewMockBrowser(t)
83+ browser.EXPECT().GetIdentifier().Return(int32(7)).Maybe()
84+ wv := &WebView{ctx: context.Background(), browser: browser, documentSeq: 1, callbacks: &port.WebViewCallbacks{
85+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
86+ }}
87+ wv.setCommittedURLsLocked("https://dumber.invalid/history", "dumb://history")
88+
89+ (&handlerSet{wv: wv}).OnFaviconUrlchange(browser, purecef.StringList(1))
90+
91+ require.Equal(t, "dumb://history", page)
92+ require.Equal(t, []string{"https://dumber.invalid/favicon.png"}, icons)
93+}
94+
75 func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {95 func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
76 prevNewStringVisitor := cefNewStringVisitor96 prevNewStringVisitor := cefNewStringVisitor
77 cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }97 cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
@@ -97,6 +117,32 @@ func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
97 require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)117 require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
98 }118 }
99 119
120+func TestCEFLoadingFinishedResolvesInternalMetadataAgainstCommittedURL(t *testing.T) {
121+ prevNewStringVisitor := cefNewStringVisitor
122+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
123+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
124+
125+ browser := cefmocks.NewMockBrowser(t)
126+ frame := cefmocks.NewMockFrame(t)
127+ browser.EXPECT().GetIdentifier().Return(int32(7)).Maybe()
128+ browser.EXPECT().GetMainFrame().Return(frame)
129+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
130+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
131+ }).Return()
132+
133+ var page string
134+ var icons []string
135+ wv := &WebView{ctx: context.Background(), browser: browser, documentSeq: 1, callbacks: &port.WebViewCallbacks{
136+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
137+ }}
138+ wv.setCommittedURLsLocked("https://dumber.invalid/history", "dumb://history")
139+
140+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
141+
142+ require.Equal(t, "dumb://history", page)
143+ require.Equal(t, []string{"https://dumber.invalid/meta.png", "https://dumber.invalid/favicon.ico"}, icons)
144+}
145+
100 func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {146 func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
101 var page string147 var page string
102 var icons []string148 var icons []string
M internal/infrastructure/cef/injection_event_test.go +43 -1
@@ -38,7 +38,7 @@ func newInjectionHarnessWithID(t *testing.T, uri string, browserID int32) *injec
3838 h.browser.EXPECT().GetMainFrame().Return(frame).Maybe()
3939 h.browser.EXPECT().GetIdentifier().Return(browserID).Maybe()
4040 h.wv = &WebView{ctx: context.Background(), browser: h.browser, documentSeq: 1}
41- h.wv.uri = uri
41+ h.wv.setCommittedURLsLocked(uri, toConceptualInternalURL(uri))
4242 return h
4343 }
4444
@@ -212,3 +212,45 @@ func TestInjectionEvent_StaleCallbackBrowserSkipped(t *testing.T) {
212212 testInjector().onLoadEndForEvent(h.wv, event)
213213 require.Zero(t, h.scriptCount(), "old-callback event must not install against the replacement browser")
214214 }
215+
216+// TestInjectionEvent_InternalSystemviewInstalls reproduces the production
217+// identity split for internal systemviews: the WebView's conceptual URI is
218+// dumb://<page> while the CEF callback frame URL is the raw committed
219+// https://dumber.invalid/<page>. The stale-event guard must compare raw
220+// against raw, so the bridge and message scripts install.
221+func TestInjectionEvent_InternalSystemviewInstalls(t *testing.T) {
222+ for _, page := range []string{"history", "favorites"} {
223+ t.Run(page, func(t *testing.T) {
224+ h := newInjectionHarness(t, "https://example.com/a")
225+ rawURL := "https://dumber.invalid/" + page
226+ h.wv.updateURI(rawURL)
227+ require.Equal(t, "dumb://"+page, h.wv.URI(), "UI callbacks must keep the conceptual URI")
228+
229+ testInjector().onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, rawURL))
230+
231+ require.Equal(t, 7, h.scriptCount(), "internal install must stay a fixed small script set")
232+ h.mu.Lock()
233+ defer h.mu.Unlock()
234+ joined := ""
235+ for _, script := range h.scripts {
236+ joined += script
237+ }
238+ require.Contains(t, joined, "dumber-theme-vars")
239+ require.Contains(t, joined, "window.dumber")
240+ })
241+ }
242+}
243+
244+// TestInjectionEvent_StaleCommittedURLSkips verifies the raw-URL stale guard:
245+// an event captured for one committed document must not install after a
246+// newer commit replaced the raw URL.
247+func TestInjectionEvent_StaleCommittedURLSkips(t *testing.T) {
248+ h := newInjectionHarness(t, "https://example.com/a")
249+ h.wv.updateURI("https://dumber.invalid/history")
250+ event := h.wv.captureInjectionEvent(h.browser, "https://dumber.invalid/history")
251+
252+ h.wv.updateURI("https://dumber.invalid/favorites")
253+
254+ testInjector().onLoadEndForEvent(h.wv, event)
255+ require.Zero(t, h.scriptCount(), "event for a superseded commit must not install")
256+}
@@ -38,7 +38,7 @@ func newInjectionHarnessWithID(t *testing.T, uri string, browserID int32) *injec
38 h.browser.EXPECT().GetMainFrame().Return(frame).Maybe()38 h.browser.EXPECT().GetMainFrame().Return(frame).Maybe()
39 h.browser.EXPECT().GetIdentifier().Return(browserID).Maybe()39 h.browser.EXPECT().GetIdentifier().Return(browserID).Maybe()
40 h.wv = &WebView{ctx: context.Background(), browser: h.browser, documentSeq: 1}40 h.wv = &WebView{ctx: context.Background(), browser: h.browser, documentSeq: 1}
41- h.wv.uri = uri41+ h.wv.setCommittedURLsLocked(uri, toConceptualInternalURL(uri))
42 return h42 return h
43 }43 }
44 44
@@ -212,3 +212,45 @@ func TestInjectionEvent_StaleCallbackBrowserSkipped(t *testing.T) {
212 testInjector().onLoadEndForEvent(h.wv, event)212 testInjector().onLoadEndForEvent(h.wv, event)
213 require.Zero(t, h.scriptCount(), "old-callback event must not install against the replacement browser")213 require.Zero(t, h.scriptCount(), "old-callback event must not install against the replacement browser")
214 }214 }
215+
216+// TestInjectionEvent_InternalSystemviewInstalls reproduces the production
217+// identity split for internal systemviews: the WebView's conceptual URI is
218+// dumb://<page> while the CEF callback frame URL is the raw committed
219+// https://dumber.invalid/<page>. The stale-event guard must compare raw
220+// against raw, so the bridge and message scripts install.
221+func TestInjectionEvent_InternalSystemviewInstalls(t *testing.T) {
222+ for _, page := range []string{"history", "favorites"} {
223+ t.Run(page, func(t *testing.T) {
224+ h := newInjectionHarness(t, "https://example.com/a")
225+ rawURL := "https://dumber.invalid/" + page
226+ h.wv.updateURI(rawURL)
227+ require.Equal(t, "dumb://"+page, h.wv.URI(), "UI callbacks must keep the conceptual URI")
228+
229+ testInjector().onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, rawURL))
230+
231+ require.Equal(t, 7, h.scriptCount(), "internal install must stay a fixed small script set")
232+ h.mu.Lock()
233+ defer h.mu.Unlock()
234+ joined := ""
235+ for _, script := range h.scripts {
236+ joined += script
237+ }
238+ require.Contains(t, joined, "dumber-theme-vars")
239+ require.Contains(t, joined, "window.dumber")
240+ })
241+ }
242+}
243+
244+// TestInjectionEvent_StaleCommittedURLSkips verifies the raw-URL stale guard:
245+// an event captured for one committed document must not install after a
246+// newer commit replaced the raw URL.
247+func TestInjectionEvent_StaleCommittedURLSkips(t *testing.T) {
248+ h := newInjectionHarness(t, "https://example.com/a")
249+ h.wv.updateURI("https://dumber.invalid/history")
250+ event := h.wv.captureInjectionEvent(h.browser, "https://dumber.invalid/history")
251+
252+ h.wv.updateURI("https://dumber.invalid/favorites")
253+
254+ testInjector().onLoadEndForEvent(h.wv, event)
255+ require.Zero(t, h.scriptCount(), "event for a superseded commit must not install")
256+}
M internal/infrastructure/cef/webview.go +48 -11
@@ -256,7 +256,13 @@ type WebView struct {
256256 popupOpenerBridgeParentURI string
257257
258258 // State cache (mutex-protected).
259+ // uri is the conceptual/public URI used by UI callbacks (for example
260+ // dumb://history). committedURL is the raw committed CEF main-frame URL
261+ // (for example https://dumber.invalid/history). The two forms are updated
262+ // together through setCommittedURLsLocked; never compare one form against
263+ // the other.
259264 uri string
265+ committedURL string
260266 title string
261267 progress float64
262268 canGoBack bool
@@ -1148,9 +1154,11 @@ func (wv *WebView) activateNativePopup(popupID int32, targetURL string) (purecef
11481154 wv.setPendingNavigationLocked(toActualInternalURL(targetURL), time.Now())
11491155 }
11501156 if strings.TrimSpace(wv.pendingURI) != "" {
1151- wv.uri = toConceptualInternalURL(wv.pendingURI)
1157+ raw := strings.TrimSpace(wv.pendingURI)
1158+ wv.setCommittedURLsLocked(raw, toConceptualInternalURL(raw))
11521159 } else {
1153- wv.uri = toConceptualInternalURL(targetURL)
1160+ raw := strings.TrimSpace(targetURL)
1161+ wv.setCommittedURLsLocked(raw, toConceptualInternalURL(raw))
11541162 }
11551163 wv.isLoading = true
11561164 return wv.client, true
@@ -1461,10 +1469,20 @@ func (wv *WebView) NativeWidget() uintptr {
14611469 // ---------------------------------------------------------------------------
14621470
14631471 func (wv *WebView) updateURI(uri string) {
1464- uri = toConceptualInternalURL(uri)
1472+ wv.setCommittedURL(uri)
1473+}
1474+
1475+// setCommittedURL is the single owner of main-frame commit identity: it
1476+// updates the raw committed CEF URL and the conceptual/public URI together,
1477+// preserving pending-navigation acknowledgement, diagnostics timestamps, and
1478+// all current callbacks. raw is the unmodified committed URL; the conceptual
1479+// URI is derived from it.
1480+func (wv *WebView) setCommittedURL(raw string) {
1481+ raw = strings.TrimSpace(raw)
1482+ uri := toConceptualInternalURL(raw)
14651483 now := time.Now()
14661484 wv.mu.Lock()
1467- wv.uri = uri
1485+ wv.setCommittedURLsLocked(raw, uri)
14681486 wv.loadDiagLastAddressAt = now
14691487 cb := wv.callbacks
14701488 pendingMatched := wv.clearPendingNavigationIfEquivalentLocked(uri)
@@ -1484,6 +1502,13 @@ func (wv *WebView) updateURI(uri string) {
14841502 wv.runNavigationCallbacks(uri)
14851503 }
14861504
1505+// setCommittedURLsLocked records the raw committed CEF main-frame URL and
1506+// the conceptual/public URI derived from it. The caller must hold wv.mu.
1507+func (wv *WebView) setCommittedURLsLocked(raw, uri string) {
1508+ wv.committedURL = raw
1509+ wv.uri = uri
1510+}
1511+
14871512 func (wv *WebView) updateTitle(title string) {
14881513 wv.mu.Lock()
14891514 wv.title = title
@@ -2285,6 +2310,17 @@ func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID i
22852310 return uri, pendingClaimReady
22862311 }
22872312
2313+// documentIdentity is the private identity of a committed main-frame
2314+// document: the callback browser identifier, the navigation intent ID, the
2315+// document sequence, and the raw committed CEF URL. Conceptual and raw URL
2316+// forms are never compared against each other.
2317+type documentIdentity struct {
2318+ browserID int32
2319+ intentID uint64
2320+ documentSeq uint64
2321+ committedURL string
2322+}
2323+
22882324 // injectionEvent captures the navigation identity observed at main-frame
22892325 // load-end so the GTK-dispatched installation can drop stale events: an
22902326 // old main frame firing during process swap, or a queued callback outlived
@@ -2294,10 +2330,7 @@ func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID i
22942330 // identity. noBrowserID marks captures taken while no browser was
22952331 // attached. The current URL alone never proves document identity.
22962332 type injectionEvent struct {
2297- browserID int32
2298- intentID uint64
2299- documentSeq uint64
2300- frameURL string
2333+ documentIdentity
23012334 }
23022335
23032336 // noBrowserID marks an injection event captured while no browser was
@@ -2315,7 +2348,7 @@ const noBrowserID = int32(-1)
23152348 // for the same document share the sequence and are installed once.
23162349 func (wv *WebView) captureInjectionEvent(browser purecef.Browser, frameURL string) injectionEvent {
23172350 if wv == nil || browser == nil {
2318- return injectionEvent{browserID: noBrowserID}
2351+ return injectionEvent{documentIdentity: documentIdentity{browserID: noBrowserID}}
23192352 }
23202353 browserID := browser.GetIdentifier()
23212354 wv.mu.RLock()
@@ -2323,8 +2356,12 @@ func (wv *WebView) captureInjectionEvent(browser purecef.Browser, frameURL strin
23232356 documentSeq := wv.documentSeq
23242357 wv.mu.RUnlock()
23252358 return injectionEvent{
2326- browserID: browserID, intentID: intentID, documentSeq: documentSeq,
2327- frameURL: strings.TrimSpace(frameURL),
2359+ documentIdentity: documentIdentity{
2360+ browserID: browserID,
2361+ intentID: intentID,
2362+ documentSeq: documentSeq,
2363+ committedURL: strings.TrimSpace(frameURL),
2364+ },
23282365 }
23292366 }
23302367
@@ -256,7 +256,13 @@ type WebView struct {
256 popupOpenerBridgeParentURI string256 popupOpenerBridgeParentURI string
257 257
258 // State cache (mutex-protected).258 // State cache (mutex-protected).
259+ // uri is the conceptual/public URI used by UI callbacks (for example
260+ // dumb://history). committedURL is the raw committed CEF main-frame URL
261+ // (for example https://dumber.invalid/history). The two forms are updated
262+ // together through setCommittedURLsLocked; never compare one form against
263+ // the other.
259 uri string264 uri string
265+ committedURL string
260 title string266 title string
261 progress float64267 progress float64
262 canGoBack bool268 canGoBack bool
@@ -1148,9 +1154,11 @@ func (wv *WebView) activateNativePopup(popupID int32, targetURL string) (purecef
1148 wv.setPendingNavigationLocked(toActualInternalURL(targetURL), time.Now())1154 wv.setPendingNavigationLocked(toActualInternalURL(targetURL), time.Now())
1149 }1155 }
1150 if strings.TrimSpace(wv.pendingURI) != "" {1156 if strings.TrimSpace(wv.pendingURI) != "" {
1151- wv.uri = toConceptualInternalURL(wv.pendingURI)1157+ raw := strings.TrimSpace(wv.pendingURI)
1158+ wv.setCommittedURLsLocked(raw, toConceptualInternalURL(raw))
1152 } else {1159 } else {
1153- wv.uri = toConceptualInternalURL(targetURL)1160+ raw := strings.TrimSpace(targetURL)
1161+ wv.setCommittedURLsLocked(raw, toConceptualInternalURL(raw))
1154 }1162 }
1155 wv.isLoading = true1163 wv.isLoading = true
1156 return wv.client, true1164 return wv.client, true
@@ -1461,10 +1469,20 @@ func (wv *WebView) NativeWidget() uintptr {
1461 // ---------------------------------------------------------------------------1469 // ---------------------------------------------------------------------------
1462 1470
1463 func (wv *WebView) updateURI(uri string) {1471 func (wv *WebView) updateURI(uri string) {
1464- uri = toConceptualInternalURL(uri)1472+ wv.setCommittedURL(uri)
1473+}
1474+
1475+// setCommittedURL is the single owner of main-frame commit identity: it
1476+// updates the raw committed CEF URL and the conceptual/public URI together,
1477+// preserving pending-navigation acknowledgement, diagnostics timestamps, and
1478+// all current callbacks. raw is the unmodified committed URL; the conceptual
1479+// URI is derived from it.
1480+func (wv *WebView) setCommittedURL(raw string) {
1481+ raw = strings.TrimSpace(raw)
1482+ uri := toConceptualInternalURL(raw)
1465 now := time.Now()1483 now := time.Now()
1466 wv.mu.Lock()1484 wv.mu.Lock()
1467- wv.uri = uri1485+ wv.setCommittedURLsLocked(raw, uri)
1468 wv.loadDiagLastAddressAt = now1486 wv.loadDiagLastAddressAt = now
1469 cb := wv.callbacks1487 cb := wv.callbacks
1470 pendingMatched := wv.clearPendingNavigationIfEquivalentLocked(uri)1488 pendingMatched := wv.clearPendingNavigationIfEquivalentLocked(uri)
@@ -1484,6 +1502,13 @@ func (wv *WebView) updateURI(uri string) {
1484 wv.runNavigationCallbacks(uri)1502 wv.runNavigationCallbacks(uri)
1485 }1503 }
1486 1504
1505+// setCommittedURLsLocked records the raw committed CEF main-frame URL and
1506+// the conceptual/public URI derived from it. The caller must hold wv.mu.
1507+func (wv *WebView) setCommittedURLsLocked(raw, uri string) {
1508+ wv.committedURL = raw
1509+ wv.uri = uri
1510+}
1511+
1487 func (wv *WebView) updateTitle(title string) {1512 func (wv *WebView) updateTitle(title string) {
1488 wv.mu.Lock()1513 wv.mu.Lock()
1489 wv.title = title1514 wv.title = title
@@ -2285,6 +2310,17 @@ func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID i
2285 return uri, pendingClaimReady2310 return uri, pendingClaimReady
2286 }2311 }
2287 2312
2313+// documentIdentity is the private identity of a committed main-frame
2314+// document: the callback browser identifier, the navigation intent ID, the
2315+// document sequence, and the raw committed CEF URL. Conceptual and raw URL
2316+// forms are never compared against each other.
2317+type documentIdentity struct {
2318+ browserID int32
2319+ intentID uint64
2320+ documentSeq uint64
2321+ committedURL string
2322+}
2323+
2288 // injectionEvent captures the navigation identity observed at main-frame2324 // injectionEvent captures the navigation identity observed at main-frame
2289 // load-end so the GTK-dispatched installation can drop stale events: an2325 // load-end so the GTK-dispatched installation can drop stale events: an
2290 // old main frame firing during process swap, or a queued callback outlived2326 // old main frame firing during process swap, or a queued callback outlived
@@ -2294,10 +2330,7 @@ func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID i
2294 // identity. noBrowserID marks captures taken while no browser was2330 // identity. noBrowserID marks captures taken while no browser was
2295 // attached. The current URL alone never proves document identity.2331 // attached. The current URL alone never proves document identity.
2296 type injectionEvent struct {2332 type injectionEvent struct {
2297- browserID int322333+ documentIdentity
2298- intentID uint64
2299- documentSeq uint64
2300- frameURL string
2301 }2334 }
2302 2335
2303 // noBrowserID marks an injection event captured while no browser was2336 // noBrowserID marks an injection event captured while no browser was
@@ -2315,7 +2348,7 @@ const noBrowserID = int32(-1)
2315 // for the same document share the sequence and are installed once.2348 // for the same document share the sequence and are installed once.
2316 func (wv *WebView) captureInjectionEvent(browser purecef.Browser, frameURL string) injectionEvent {2349 func (wv *WebView) captureInjectionEvent(browser purecef.Browser, frameURL string) injectionEvent {
2317 if wv == nil || browser == nil {2350 if wv == nil || browser == nil {
2318- return injectionEvent{browserID: noBrowserID}2351+ return injectionEvent{documentIdentity: documentIdentity{browserID: noBrowserID}}
2319 }2352 }
2320 browserID := browser.GetIdentifier()2353 browserID := browser.GetIdentifier()
2321 wv.mu.RLock()2354 wv.mu.RLock()
@@ -2323,8 +2356,12 @@ func (wv *WebView) captureInjectionEvent(browser purecef.Browser, frameURL strin
2323 documentSeq := wv.documentSeq2356 documentSeq := wv.documentSeq
2324 wv.mu.RUnlock()2357 wv.mu.RUnlock()
2325 return injectionEvent{2358 return injectionEvent{
2326- browserID: browserID, intentID: intentID, documentSeq: documentSeq,2359+ documentIdentity: documentIdentity{
2327- frameURL: strings.TrimSpace(frameURL),2360+ browserID: browserID,
2361+ intentID: intentID,
2362+ documentSeq: documentSeq,
2363+ committedURL: strings.TrimSpace(frameURL),
2364+ },
2328 }2365 }
2329 }2366 }
2330 2367
M internal/infrastructure/cef/webview_handlers.go +13 -6
@@ -183,13 +183,16 @@ func (h *handlerSet) OnFaviconUrlchange(browser purecef.Browser, iconURLs purece
183183 return
184184 }
185185 h.wv.mu.RLock()
186- pageURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.callbacks, h.wv.browser, h.wv.documentSeq
186+ pageURL, committedURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.committedURL, h.wv.callbacks, h.wv.browser, h.wv.documentSeq
187187 h.wv.mu.RUnlock()
188188 if cb == nil || cb.OnFaviconURLChanged == nil || browser == nil || currentBrowser == nil ||
189189 browser.GetIdentifier() != currentBrowser.GetIdentifier() {
190190 return
191191 }
192- candidates := resolveFaviconCandidates(pageURL, decodeCEFStringList(iconURLs))
192+ if committedURL == "" {
193+ committedURL = pageURL
194+ }
195+ candidates := resolveFaviconCandidates(committedURL, decodeCEFStringList(iconURLs))
193196 if len(candidates) == 0 {
194197 return
195198 }
@@ -389,9 +392,13 @@ func (h *handlerSet) OnLoadingStateChange(browser purecef.Browser, isloading, ca
389392 h.wv.mu.RLock()
390393 cb := h.wv.callbacks
391394 pageURL := h.wv.uri
395+ committedURL := h.wv.committedURL
392396 h.wv.mu.RUnlock()
397+ if committedURL == "" {
398+ committedURL = pageURL
399+ }
393400 if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
394- h.emitDiscoveredFaviconCandidates(browser, pageURL, cb)
401+ h.emitDiscoveredFaviconCandidates(browser, pageURL, committedURL, cb)
395402 }
396403 if cb != nil && cb.OnLoadChanged != nil {
397404 if loading {
@@ -432,7 +439,7 @@ func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) fun
432439 }
433440 }
434441
435-func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {
442+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL, committedURL string, cb *port.WebViewCallbacks) {
436443 if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
437444 return
438445 }
@@ -455,7 +462,7 @@ func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pa
455462 }
456463 h.wv.mu.Unlock()
457464 }
458- fallback := fallbackFaviconCandidates(pageURL)
465+ fallback := fallbackFaviconCandidates(committedURL)
459466 emit := func(candidates []string) {
460467 if len(candidates) == 0 {
461468 return
@@ -496,7 +503,7 @@ func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pa
496503 if engineWon {
497504 return
498505 }
499- candidates := DiscoverFaviconCandidates(pageURL, source)
506+ candidates := DiscoverFaviconCandidates(committedURL, source)
500507 if len(candidates) == 0 {
501508 candidates = fallback
502509 }
@@ -183,13 +183,16 @@ func (h *handlerSet) OnFaviconUrlchange(browser purecef.Browser, iconURLs purece
183 return183 return
184 }184 }
185 h.wv.mu.RLock()185 h.wv.mu.RLock()
186- pageURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.callbacks, h.wv.browser, h.wv.documentSeq186+ pageURL, committedURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.committedURL, h.wv.callbacks, h.wv.browser, h.wv.documentSeq
187 h.wv.mu.RUnlock()187 h.wv.mu.RUnlock()
188 if cb == nil || cb.OnFaviconURLChanged == nil || browser == nil || currentBrowser == nil ||188 if cb == nil || cb.OnFaviconURLChanged == nil || browser == nil || currentBrowser == nil ||
189 browser.GetIdentifier() != currentBrowser.GetIdentifier() {189 browser.GetIdentifier() != currentBrowser.GetIdentifier() {
190 return190 return
191 }191 }
192- candidates := resolveFaviconCandidates(pageURL, decodeCEFStringList(iconURLs))192+ if committedURL == "" {
193+ committedURL = pageURL
194+ }
195+ candidates := resolveFaviconCandidates(committedURL, decodeCEFStringList(iconURLs))
193 if len(candidates) == 0 {196 if len(candidates) == 0 {
194 return197 return
195 }198 }
@@ -389,9 +392,13 @@ func (h *handlerSet) OnLoadingStateChange(browser purecef.Browser, isloading, ca
389 h.wv.mu.RLock()392 h.wv.mu.RLock()
390 cb := h.wv.callbacks393 cb := h.wv.callbacks
391 pageURL := h.wv.uri394 pageURL := h.wv.uri
395+ committedURL := h.wv.committedURL
392 h.wv.mu.RUnlock()396 h.wv.mu.RUnlock()
397+ if committedURL == "" {
398+ committedURL = pageURL
399+ }
393 if !loading && cb != nil && cb.OnFaviconURLChanged != nil {400 if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
394- h.emitDiscoveredFaviconCandidates(browser, pageURL, cb)401+ h.emitDiscoveredFaviconCandidates(browser, pageURL, committedURL, cb)
395 }402 }
396 if cb != nil && cb.OnLoadChanged != nil {403 if cb != nil && cb.OnLoadChanged != nil {
397 if loading {404 if loading {
@@ -432,7 +439,7 @@ func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) fun
432 }439 }
433 }440 }
434 441
435-func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {442+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL, committedURL string, cb *port.WebViewCallbacks) {
436 if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {443 if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
437 return444 return
438 }445 }
@@ -455,7 +462,7 @@ func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pa
455 }462 }
456 h.wv.mu.Unlock()463 h.wv.mu.Unlock()
457 }464 }
458- fallback := fallbackFaviconCandidates(pageURL)465+ fallback := fallbackFaviconCandidates(committedURL)
459 emit := func(candidates []string) {466 emit := func(candidates []string) {
460 if len(candidates) == 0 {467 if len(candidates) == 0 {
461 return468 return
@@ -496,7 +503,7 @@ func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pa
496 if engineWon {503 if engineWon {
497 return504 return
498 }505 }
499- candidates := DiscoverFaviconCandidates(pageURL, source)506+ candidates := DiscoverFaviconCandidates(committedURL, source)
500 if len(candidates) == 0 {507 if len(candidates) == 0 {
501 candidates = fallback508 candidates = fallback
502 }509 }
M internal/infrastructure/cef/webview_handlers_test.go +4 -0
@@ -231,6 +231,10 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe
231231 require.Equal(t, int32(55), wv.nativePopupID)
232232 require.Same(t, parent, wv.nativePopupParent)
233233 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
234+ wv.mu.RLock()
235+ require.Equal(t, "https://example.com/oauth", wv.committedURL)
236+ require.Equal(t, "https://example.com/oauth", wv.uri)
237+ wv.mu.RUnlock()
234238
235239 wv.updateURI("about:blank")
236240 wv.updateLoadState(false, false, false)
@@ -231,6 +231,10 @@ func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBe
231 require.Equal(t, int32(55), wv.nativePopupID)231 require.Equal(t, int32(55), wv.nativePopupID)
232 require.Same(t, parent, wv.nativePopupParent)232 require.Same(t, parent, wv.nativePopupParent)
233 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())233 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
234+ wv.mu.RLock()
235+ require.Equal(t, "https://example.com/oauth", wv.committedURL)
236+ require.Equal(t, "https://example.com/oauth", wv.uri)
237+ wv.mu.RUnlock()
234 238
235 wv.updateURI("about:blank")239 wv.updateURI("about:blank")
236 wv.updateLoadState(false, false, false)240 wv.updateLoadState(false, false, false)

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