detached c7fcb6d

fix(webkit): harden editable focus lifecycle

commit
c7fcb6dce4e4602a67f449561d9910e438f88be2
author
brice · · 2026-07-29 05:04 UTC
parent
3ad27cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +255 -49
A internal/infrastructure/webkit/editable_focus_token_test.go +74 -0
@@ -0,0 +1,74 @@
1+package webkit
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNewEditableFocusBridgeToken_UsesRandomHex(t *testing.T) {
12+ old := editableFocusTokenRand
13+ t.Cleanup(func() { editableFocusTokenRand = old })
14+ editableFocusTokenRand = func(b []byte) (int, error) {
15+ for i := range b {
16+ b[i] = byte(i + 1)
17+ }
18+ return len(b), nil
19+ }
20+
21+ token := newEditableFocusBridgeToken()
22+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", token)
23+}
24+
25+func TestNewEditableFocusBridgeToken_RandFailureReturnsEmpty(t *testing.T) {
26+ old := editableFocusTokenRand
27+ t.Cleanup(func() { editableFocusTokenRand = old })
28+ editableFocusTokenRand = func([]byte) (int, error) {
29+ return 0, errors.New("entropy exhausted")
30+ }
31+
32+ token := newEditableFocusBridgeToken()
33+ require.Empty(t, token)
34+}
35+
36+func TestEditableFocusBridgeToken_ConcurrentAccess(t *testing.T) {
37+ t.Parallel()
38+ wv := &WebView{}
39+ wv.setEditableFocusBridgeToken("initial-token")
40+
41+ var wg sync.WaitGroup
42+ for range 32 {
43+ wg.Add(2)
44+ go func() {
45+ defer wg.Done()
46+ for range 200 {
47+ _ = wv.EditableFocusBridgeToken()
48+ _ = wv.matchesEditableFocusBridgeToken("initial-token")
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for i := range 200 {
54+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
55+ if i%17 == 0 {
56+ wv.setEditableFocusBridgeToken("initial-token")
57+ }
58+ }
59+ }()
60+ }
61+ wg.Wait()
62+}
63+
64+func TestMatchesEditableFocusBridgeToken_RejectsEmpty(t *testing.T) {
65+ wv := &WebView{}
66+ wv.setEditableFocusBridgeToken("")
67+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
68+ require.False(t, wv.matchesEditableFocusBridgeToken("anything"))
69+
70+ wv.setEditableFocusBridgeToken("secret")
71+ require.True(t, wv.matchesEditableFocusBridgeToken("secret"))
72+ require.False(t, wv.matchesEditableFocusBridgeToken("other"))
73+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
74+}
@@ -0,0 +1,74 @@
1+package webkit
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNewEditableFocusBridgeToken_UsesRandomHex(t *testing.T) {
12+ old := editableFocusTokenRand
13+ t.Cleanup(func() { editableFocusTokenRand = old })
14+ editableFocusTokenRand = func(b []byte) (int, error) {
15+ for i := range b {
16+ b[i] = byte(i + 1)
17+ }
18+ return len(b), nil
19+ }
20+
21+ token := newEditableFocusBridgeToken()
22+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", token)
23+}
24+
25+func TestNewEditableFocusBridgeToken_RandFailureReturnsEmpty(t *testing.T) {
26+ old := editableFocusTokenRand
27+ t.Cleanup(func() { editableFocusTokenRand = old })
28+ editableFocusTokenRand = func([]byte) (int, error) {
29+ return 0, errors.New("entropy exhausted")
30+ }
31+
32+ token := newEditableFocusBridgeToken()
33+ require.Empty(t, token)
34+}
35+
36+func TestEditableFocusBridgeToken_ConcurrentAccess(t *testing.T) {
37+ t.Parallel()
38+ wv := &WebView{}
39+ wv.setEditableFocusBridgeToken("initial-token")
40+
41+ var wg sync.WaitGroup
42+ for range 32 {
43+ wg.Add(2)
44+ go func() {
45+ defer wg.Done()
46+ for range 200 {
47+ _ = wv.EditableFocusBridgeToken()
48+ _ = wv.matchesEditableFocusBridgeToken("initial-token")
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for i := range 200 {
54+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
55+ if i%17 == 0 {
56+ wv.setEditableFocusBridgeToken("initial-token")
57+ }
58+ }
59+ }()
60+ }
61+ wg.Wait()
62+}
63+
64+func TestMatchesEditableFocusBridgeToken_RejectsEmpty(t *testing.T) {
65+ wv := &WebView{}
66+ wv.setEditableFocusBridgeToken("")
67+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
68+ require.False(t, wv.matchesEditableFocusBridgeToken("anything"))
69+
70+ wv.setEditableFocusBridgeToken("secret")
71+ require.True(t, wv.matchesEditableFocusBridgeToken("secret"))
72+ require.False(t, wv.matchesEditableFocusBridgeToken("other"))
73+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
74+}
M internal/infrastructure/webkit/injector.go +5 -2
@@ -196,6 +196,7 @@ func buildAccentDetectionScript(token string) string {
196196 let pressedKey = null;
197197
198198 function postEditableFocus(editable) {
199+ if (!editableFocusToken) return;
199200 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
200201 window.webkit.messageHandlers.dumber.postMessage({
201202 type: 'editable_focus_changed',
@@ -487,8 +488,10 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
487488 // 8. Inject accent key detection for all pages and all frames (unconditional).
488489 // JS only reports keydown/keyup events; Go handles timing and picker display.
489490 accentScript := accentDetectionScript
490- if wv := LookupWebView(webviewID); wv != nil && wv.editableFocusBridgeToken != "" {
491- accentScript = buildAccentDetectionScript(wv.editableFocusBridgeToken)
491+ if wv := LookupWebView(webviewID); wv != nil {
492+ if token := wv.EditableFocusBridgeToken(); token != "" {
493+ accentScript = buildAccentDetectionScript(token)
494+ }
492495 }
493496 addScript(
494497 webkit.NewUserScript(
@@ -196,6 +196,7 @@ func buildAccentDetectionScript(token string) string {
196 let pressedKey = null;196 let pressedKey = null;
197 197
198 function postEditableFocus(editable) {198 function postEditableFocus(editable) {
199+ if (!editableFocusToken) return;
199 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;200 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
200 window.webkit.messageHandlers.dumber.postMessage({201 window.webkit.messageHandlers.dumber.postMessage({
201 type: 'editable_focus_changed',202 type: 'editable_focus_changed',
@@ -487,8 +488,10 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
487 // 8. Inject accent key detection for all pages and all frames (unconditional).488 // 8. Inject accent key detection for all pages and all frames (unconditional).
488 // JS only reports keydown/keyup events; Go handles timing and picker display.489 // JS only reports keydown/keyup events; Go handles timing and picker display.
489 accentScript := accentDetectionScript490 accentScript := accentDetectionScript
490- if wv := LookupWebView(webviewID); wv != nil && wv.editableFocusBridgeToken != "" {491+ if wv := LookupWebView(webviewID); wv != nil {
491- accentScript = buildAccentDetectionScript(wv.editableFocusBridgeToken)492+ if token := wv.EditableFocusBridgeToken(); token != "" {
493+ accentScript = buildAccentDetectionScript(token)
494+ }
492 }495 }
493 addScript(496 addScript(
494 webkit.NewUserScript(497 webkit.NewUserScript(
M internal/infrastructure/webkit/injector_accent_test.go +10 -0
@@ -45,6 +45,16 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) {
4545 assert.Contains(t, accentDetectionScript, `window.__dumber_lastEditableEl = document.activeElement`)
4646 }
4747
48+func TestBuildAccentDetectionScript_EmptyTokenReturnsBeforeHandlerLookup(t *testing.T) {
49+ empty := buildAccentDetectionScript("")
50+ assert.Contains(t, empty, "if (!editableFocusToken) return;")
51+ nonEmpty := buildAccentDetectionScript("abc123")
52+ assert.Contains(t, nonEmpty, `const editableFocusToken = "abc123";`)
53+ assert.Contains(t, nonEmpty, "if (!editableFocusToken) return;")
54+ assert.Contains(t, nonEmpty, `postEditableFocus(true)`)
55+ assert.Contains(t, nonEmpty, "window.webkit.messageHandlers.dumber.postMessage")
56+}
57+
4858 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
4959 script := buildExplicitCopyScript()
5060
@@ -45,6 +45,16 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) {
45 assert.Contains(t, accentDetectionScript, `window.__dumber_lastEditableEl = document.activeElement`)45 assert.Contains(t, accentDetectionScript, `window.__dumber_lastEditableEl = document.activeElement`)
46 }46 }
47 47
48+func TestBuildAccentDetectionScript_EmptyTokenReturnsBeforeHandlerLookup(t *testing.T) {
49+ empty := buildAccentDetectionScript("")
50+ assert.Contains(t, empty, "if (!editableFocusToken) return;")
51+ nonEmpty := buildAccentDetectionScript("abc123")
52+ assert.Contains(t, nonEmpty, `const editableFocusToken = "abc123";`)
53+ assert.Contains(t, nonEmpty, "if (!editableFocusToken) return;")
54+ assert.Contains(t, nonEmpty, `postEditableFocus(true)`)
55+ assert.Contains(t, nonEmpty, "window.webkit.messageHandlers.dumber.postMessage")
56+}
57+
48 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {58 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
49 script := buildExplicitCopyScript()59 script := buildExplicitCopyScript()
50 60
M internal/infrastructure/webkit/messaging.go +1 -1
@@ -311,7 +311,7 @@ func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Me
311311 return true
312312 }
313313 }
314- if payload.Token == "" || payload.Token != senderWV.editableFocusBridgeToken {
314+ if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) {
315315 logging.FromContext(r.baseCtx).Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")
316316 return true
317317 }
@@ -311,7 +311,7 @@ func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Me
311 return true311 return true
312 }312 }
313 }313 }
314- if payload.Token == "" || payload.Token != senderWV.editableFocusBridgeToken {314+ if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) {
315 logging.FromContext(r.baseCtx).Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")315 logging.FromContext(r.baseCtx).Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")
316 return true316 return true
317 }317 }
M internal/infrastructure/webkit/messaging_test.go +5 -3
@@ -60,13 +60,15 @@ func TestHandleAllowlistedEditableFocusMessage_AllowsUntrustedPage(t *testing.T)
6060 func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) {
6161 router := NewMessageRouter(context.Background())
6262 wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"}
63+ wv.ResetForPoolReuse()
64+
65+ // Re-register after reset so a stale token is the only reason handling is a noop.
6366 called := false
6467 wv.SetCallbacks(&port.WebViewCallbacks{
6568 OnEditableFocusChanged: func(bool) {
6669 called = true
6770 },
6871 })
69- wv.ResetForPoolReuse()
7072
7173 handled := router.handleAllowlistedBridgeMessage(wv, Message{
7274 Type: "editable_focus_changed",
@@ -82,8 +84,8 @@ func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) {
8284
8385 wv.ResetForPoolReuse()
8486
85- require.NotEmpty(t, wv.editableFocusBridgeToken)
86- require.NotEqual(t, "secret-token", wv.editableFocusBridgeToken)
87+ require.NotEmpty(t, wv.EditableFocusBridgeToken())
88+ require.NotEqual(t, "secret-token", wv.EditableFocusBridgeToken())
8789 }
8890
8991 func TestIsTrustedBridgeURI(t *testing.T) {
@@ -60,13 +60,15 @@ func TestHandleAllowlistedEditableFocusMessage_AllowsUntrustedPage(t *testing.T)
60 func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) {60 func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) {
61 router := NewMessageRouter(context.Background())61 router := NewMessageRouter(context.Background())
62 wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"}62 wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"}
63+ wv.ResetForPoolReuse()
64+
65+ // Re-register after reset so a stale token is the only reason handling is a noop.
63 called := false66 called := false
64 wv.SetCallbacks(&port.WebViewCallbacks{67 wv.SetCallbacks(&port.WebViewCallbacks{
65 OnEditableFocusChanged: func(bool) {68 OnEditableFocusChanged: func(bool) {
66 called = true69 called = true
67 },70 },
68 })71 })
69- wv.ResetForPoolReuse()
70 72
71 handled := router.handleAllowlistedBridgeMessage(wv, Message{73 handled := router.handleAllowlistedBridgeMessage(wv, Message{
72 Type: "editable_focus_changed",74 Type: "editable_focus_changed",
@@ -82,8 +84,8 @@ func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) {
82 84
83 wv.ResetForPoolReuse()85 wv.ResetForPoolReuse()
84 86
85- require.NotEmpty(t, wv.editableFocusBridgeToken)87+ require.NotEmpty(t, wv.EditableFocusBridgeToken())
86- require.NotEqual(t, "secret-token", wv.editableFocusBridgeToken)88+ require.NotEqual(t, "secret-token", wv.EditableFocusBridgeToken())
87 }89 }
88 90
89 func TestIsTrustedBridgeURI(t *testing.T) {91 func TestIsTrustedBridgeURI(t *testing.T) {
M internal/infrastructure/webkit/pool.go +11 -1
@@ -178,8 +178,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178178 }
179179
180180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
181+ alreadyAttached := wv.FrontendAttached()
181182 _ = wv.AttachFrontend(ctx, p.injector, p.router)
182- if p.injector != nil && wv.ucm != nil {
183+ // Reinjection is only for already-attached reuse after ResetForPoolReuse.
184+ // First AttachFrontend already injected; avoid an immediate double inject.
185+ if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil {
183186 wv.ucm.RemoveAllScripts()
184187 wv.ucm.RemoveAllStyleSheets()
185188 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
@@ -245,6 +248,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP
245248 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
246249 }
247250
251+// needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded
252+// earlier, so pool acquire must wipe and reinject after reuse. First-time attach
253+// injects inside AttachFrontend and must not immediately reinject.
254+func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool {
255+ return alreadyAttached
256+}
257+
248258 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
249259 return settings != nil && wv != nil && wv.inner != nil
250260 }
@@ -178,8 +178,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178 }178 }
179 179
180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
181+ alreadyAttached := wv.FrontendAttached()
181 _ = wv.AttachFrontend(ctx, p.injector, p.router)182 _ = wv.AttachFrontend(ctx, p.injector, p.router)
182- if p.injector != nil && wv.ucm != nil {183+ // Reinjection is only for already-attached reuse after ResetForPoolReuse.
184+ // First AttachFrontend already injected; avoid an immediate double inject.
185+ if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil {
183 wv.ucm.RemoveAllScripts()186 wv.ucm.RemoveAllScripts()
184 wv.ucm.RemoveAllStyleSheets()187 wv.ucm.RemoveAllStyleSheets()
185 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())188 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
@@ -245,6 +248,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP
245 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0248 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
246 }249 }
247 250
251+// needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded
252+// earlier, so pool acquire must wipe and reinject after reuse. First-time attach
253+// injects inside AttachFrontend and must not immediately reinject.
254+func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool {
255+ return alreadyAttached
256+}
257+
248 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {258 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
249 return settings != nil && wv != nil && wv.inner != nil259 return settings != nil && wv != nil && wv.inner != nil
250 }260 }
M internal/infrastructure/webkit/pool_policy_test.go +5 -0
@@ -16,3 +16,8 @@ func TestResolveReusePolicy(t *testing.T) {
1616 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")
1717 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))
1818 }
19+
20+func TestNeedsFrontendReinjectionOnAcquire(t *testing.T) {
21+ assert.False(t, needsFrontendReinjectionOnAcquire(false), "first AttachFrontend must not double-inject")
22+ assert.True(t, needsFrontendReinjectionOnAcquire(true), "already-attached reuse must reinject")
23+}
@@ -16,3 +16,8 @@ func TestResolveReusePolicy(t *testing.T) {
16 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")16 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")
17 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))17 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))
18 }18 }
19+
20+func TestNeedsFrontendReinjectionOnAcquire(t *testing.T) {
21+ assert.False(t, needsFrontendReinjectionOnAcquire(false), "first AttachFrontend must not double-inject")
22+ assert.True(t, needsFrontendReinjectionOnAcquire(true), "already-attached reuse must reinject")
23+}
M internal/infrastructure/webkit/webview.go +92 -40
@@ -3,6 +3,7 @@ package webkit
33 import (
44 "context"
55 "crypto/rand"
6+ "crypto/subtle"
67 "encoding/hex"
78 "fmt"
89 "net/url"
@@ -40,10 +41,24 @@ var _ port.OAuthCallbackCapable = (*WebView)(nil)
4041 var _ port.PageScrollable = (*WebView)(nil)
4142
4243 var buildPageScrollFallbackJS = webutil.BuildScrollByJS
43-var runPageScrollFallbackJS = func(wv *WebView, ctx context.Context, script string) {
44+var runPageScrollFallbackJS = func(ctx context.Context, wv *WebView, script string) {
4445 wv.RunJavaScript(ctx, script)
4546 }
4647
48+// editableFocusTokenRand is the injectable CSPRNG seam for bridge tokens.
49+// Tests may override it; production uses crypto/rand.Read.
50+var editableFocusTokenRand = rand.Read
51+
52+const editableFocusBridgeTokenBytes = 16
53+
54+func newEditableFocusBridgeToken() string {
55+ buf := make([]byte, editableFocusBridgeTokenBytes)
56+ if _, err := editableFocusTokenRand(buf); err != nil {
57+ return ""
58+ }
59+ return hex.EncodeToString(buf)
60+}
61+
4762 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
4863 // Infrastructure layer uses the type defined in the application port.
4964 type WebViewID = port.WebViewID
@@ -223,18 +238,8 @@ const (
223238 runJSNonFatalLogInterval = 30 * time.Second
224239 runJSAggregateLogEvery = 20
225240 runJSUnknown = "unknown"
226-
227- editableFocusBridgeTokenBytes = 16
228241 )
229242
230-func newEditableFocusBridgeToken() string {
231- buf := make([]byte, editableFocusBridgeTokenBytes)
232- if _, err := rand.Read(buf); err != nil {
233- return fmt.Sprintf("editable-focus-%d", time.Now().UnixNano())
234- }
235- return hex.EncodeToString(buf)
236-}
237-
238243 type findControllerAdapter struct {
239244 fc *webkit.FindController
240245 }
@@ -471,38 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() {
471476 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
472477 uri := inner.GetUri()
473478 title := inner.GetTitle()
474-
475- wv.mu.Lock()
476- wv.uri = uri
477- wv.title = title
478- // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
479- // which fires for both traditional navigation and SPA history.pushState()
480- wv.progress = inner.GetEstimatedLoadProgress()
481-
482- switch event {
483- case webkit.LoadStartedValue:
484- wv.navigationActive.Store(true)
485- wv.isLoading = true
486- wv.logger.Debug().Str("uri", uri).Msg("load started")
487- wv.dispatchEditableFocusChanged(false)
488- case webkit.LoadRedirectedValue:
489- wv.logger.Debug().Str("uri", uri).Msg("load redirected")
490- case webkit.LoadCommittedValue:
491- wv.logger.Debug().Str("uri", uri).Msg("load committed")
492- case webkit.LoadFinishedValue:
493- wv.isLoading = false
494- wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
495- }
496- wv.mu.Unlock()
497-
498- if wv.OnLoadChanged != nil {
499- wv.OnLoadChanged(LoadEvent(event))
500- }
479+ progress := inner.GetEstimatedLoadProgress()
480+ wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
501481 }
502482 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
503483 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
504484 }
505485
486+// handleLoadChanged updates load state then notifies outside wv.mu so callbacks
487+// may re-enter URI()/State() without deadlocking.
488+func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) {
489+ wv.mu.Lock()
490+ wv.uri = uri
491+ wv.title = title
492+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
493+ // which fires for both traditional navigation and SPA history.pushState()
494+ wv.progress = progress
495+
496+ notifyEditableBlur := false
497+ switch event {
498+ case LoadStarted:
499+ wv.navigationActive.Store(true)
500+ wv.isLoading = true
501+ wv.logger.Debug().Str("uri", uri).Msg("load started")
502+ notifyEditableBlur = true
503+ case LoadRedirected:
504+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
505+ case LoadCommitted:
506+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
507+ case LoadFinished:
508+ wv.isLoading = false
509+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
510+ }
511+ wv.mu.Unlock()
512+
513+ if notifyEditableBlur {
514+ wv.dispatchEditableFocusChanged(false)
515+ }
516+
517+ if wv.OnLoadChanged != nil {
518+ wv.OnLoadChanged(event)
519+ }
520+}
521+
506522 func (wv *WebView) connectLoadFailedSignal() {
507523 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
508524 wv.logger.Warn().
@@ -2083,7 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() {
20832099 return
20842100 }
20852101 wv.generation.Add(1)
2086- wv.editableFocusBridgeToken = newEditableFocusBridgeToken()
2102+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
20872103
20882104 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
20892105 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2424,6 +2440,42 @@ func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollReques
24242440 return fmt.Errorf("webview %d is destroyed", wv.id)
24252441 }
24262442 js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)
2427- runPageScrollFallbackJS(wv, ctx, js)
2443+ runPageScrollFallbackJS(ctx, wv, js)
24282444 return nil
24292445 }
2446+
2447+// FrontendAttached reports whether AttachFrontend has already wired scripts/router.
2448+func (wv *WebView) FrontendAttached() bool {
2449+ return wv != nil && wv.frontendAttached.Load()
2450+}
2451+
2452+// EditableFocusBridgeToken returns the current allowlisted bridge auth token.
2453+func (wv *WebView) EditableFocusBridgeToken() string {
2454+ if wv == nil {
2455+ return ""
2456+ }
2457+ wv.mu.RLock()
2458+ defer wv.mu.RUnlock()
2459+ return wv.editableFocusBridgeToken
2460+}
2461+
2462+func (wv *WebView) setEditableFocusBridgeToken(token string) {
2463+ if wv == nil {
2464+ return
2465+ }
2466+ wv.mu.Lock()
2467+ wv.editableFocusBridgeToken = token
2468+ wv.mu.Unlock()
2469+}
2470+
2471+// matchesEditableFocusBridgeToken compares a candidate token in constant time.
2472+func (wv *WebView) matchesEditableFocusBridgeToken(candidate string) bool {
2473+ if wv == nil || candidate == "" {
2474+ return false
2475+ }
2476+ token := wv.EditableFocusBridgeToken()
2477+ if token == "" {
2478+ return false
2479+ }
2480+ return subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1
2481+}
@@ -3,6 +3,7 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "crypto/rand"5 "crypto/rand"
6+ "crypto/subtle"
6 "encoding/hex"7 "encoding/hex"
7 "fmt"8 "fmt"
8 "net/url"9 "net/url"
@@ -40,10 +41,24 @@ var _ port.OAuthCallbackCapable = (*WebView)(nil)
40 var _ port.PageScrollable = (*WebView)(nil)41 var _ port.PageScrollable = (*WebView)(nil)
41 42
42 var buildPageScrollFallbackJS = webutil.BuildScrollByJS43 var buildPageScrollFallbackJS = webutil.BuildScrollByJS
43-var runPageScrollFallbackJS = func(wv *WebView, ctx context.Context, script string) {44+var runPageScrollFallbackJS = func(ctx context.Context, wv *WebView, script string) {
44 wv.RunJavaScript(ctx, script)45 wv.RunJavaScript(ctx, script)
45 }46 }
46 47
48+// editableFocusTokenRand is the injectable CSPRNG seam for bridge tokens.
49+// Tests may override it; production uses crypto/rand.Read.
50+var editableFocusTokenRand = rand.Read
51+
52+const editableFocusBridgeTokenBytes = 16
53+
54+func newEditableFocusBridgeToken() string {
55+ buf := make([]byte, editableFocusBridgeTokenBytes)
56+ if _, err := editableFocusTokenRand(buf); err != nil {
57+ return ""
58+ }
59+ return hex.EncodeToString(buf)
60+}
61+
47 // WebViewID is an alias to port.WebViewID for clean architecture compliance.62 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
48 // Infrastructure layer uses the type defined in the application port.63 // Infrastructure layer uses the type defined in the application port.
49 type WebViewID = port.WebViewID64 type WebViewID = port.WebViewID
@@ -223,18 +238,8 @@ const (
223 runJSNonFatalLogInterval = 30 * time.Second238 runJSNonFatalLogInterval = 30 * time.Second
224 runJSAggregateLogEvery = 20239 runJSAggregateLogEvery = 20
225 runJSUnknown = "unknown"240 runJSUnknown = "unknown"
226-
227- editableFocusBridgeTokenBytes = 16
228 )241 )
229 242
230-func newEditableFocusBridgeToken() string {
231- buf := make([]byte, editableFocusBridgeTokenBytes)
232- if _, err := rand.Read(buf); err != nil {
233- return fmt.Sprintf("editable-focus-%d", time.Now().UnixNano())
234- }
235- return hex.EncodeToString(buf)
236-}
237-
238 type findControllerAdapter struct {243 type findControllerAdapter struct {
239 fc *webkit.FindController244 fc *webkit.FindController
240 }245 }
@@ -471,38 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() {
471 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {476 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
472 uri := inner.GetUri()477 uri := inner.GetUri()
473 title := inner.GetTitle()478 title := inner.GetTitle()
474-479+ progress := inner.GetEstimatedLoadProgress()
475- wv.mu.Lock()480+ wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
476- wv.uri = uri
477- wv.title = title
478- // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
479- // which fires for both traditional navigation and SPA history.pushState()
480- wv.progress = inner.GetEstimatedLoadProgress()
481-
482- switch event {
483- case webkit.LoadStartedValue:
484- wv.navigationActive.Store(true)
485- wv.isLoading = true
486- wv.logger.Debug().Str("uri", uri).Msg("load started")
487- wv.dispatchEditableFocusChanged(false)
488- case webkit.LoadRedirectedValue:
489- wv.logger.Debug().Str("uri", uri).Msg("load redirected")
490- case webkit.LoadCommittedValue:
491- wv.logger.Debug().Str("uri", uri).Msg("load committed")
492- case webkit.LoadFinishedValue:
493- wv.isLoading = false
494- wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
495- }
496- wv.mu.Unlock()
497-
498- if wv.OnLoadChanged != nil {
499- wv.OnLoadChanged(LoadEvent(event))
500- }
501 }481 }
502 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)482 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
503 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))483 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
504 }484 }
505 485
486+// handleLoadChanged updates load state then notifies outside wv.mu so callbacks
487+// may re-enter URI()/State() without deadlocking.
488+func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) {
489+ wv.mu.Lock()
490+ wv.uri = uri
491+ wv.title = title
492+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
493+ // which fires for both traditional navigation and SPA history.pushState()
494+ wv.progress = progress
495+
496+ notifyEditableBlur := false
497+ switch event {
498+ case LoadStarted:
499+ wv.navigationActive.Store(true)
500+ wv.isLoading = true
501+ wv.logger.Debug().Str("uri", uri).Msg("load started")
502+ notifyEditableBlur = true
503+ case LoadRedirected:
504+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
505+ case LoadCommitted:
506+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
507+ case LoadFinished:
508+ wv.isLoading = false
509+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
510+ }
511+ wv.mu.Unlock()
512+
513+ if notifyEditableBlur {
514+ wv.dispatchEditableFocusChanged(false)
515+ }
516+
517+ if wv.OnLoadChanged != nil {
518+ wv.OnLoadChanged(event)
519+ }
520+}
521+
506 func (wv *WebView) connectLoadFailedSignal() {522 func (wv *WebView) connectLoadFailedSignal() {
507 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {523 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
508 wv.logger.Warn().524 wv.logger.Warn().
@@ -2083,7 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() {
2083 return2099 return
2084 }2100 }
2085 wv.generation.Add(1)2101 wv.generation.Add(1)
2086- wv.editableFocusBridgeToken = newEditableFocusBridgeToken()2102+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
2087 2103
2088 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.2104 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
2089 // They will be reconnected when the WebView is re-acquired from the pool.2105 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2424,6 +2440,42 @@ func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollReques
2424 return fmt.Errorf("webview %d is destroyed", wv.id)2440 return fmt.Errorf("webview %d is destroyed", wv.id)
2425 }2441 }
2426 js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)2442 js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)
2427- runPageScrollFallbackJS(wv, ctx, js)2443+ runPageScrollFallbackJS(ctx, wv, js)
2428 return nil2444 return nil
2429 }2445 }
2446+
2447+// FrontendAttached reports whether AttachFrontend has already wired scripts/router.
2448+func (wv *WebView) FrontendAttached() bool {
2449+ return wv != nil && wv.frontendAttached.Load()
2450+}
2451+
2452+// EditableFocusBridgeToken returns the current allowlisted bridge auth token.
2453+func (wv *WebView) EditableFocusBridgeToken() string {
2454+ if wv == nil {
2455+ return ""
2456+ }
2457+ wv.mu.RLock()
2458+ defer wv.mu.RUnlock()
2459+ return wv.editableFocusBridgeToken
2460+}
2461+
2462+func (wv *WebView) setEditableFocusBridgeToken(token string) {
2463+ if wv == nil {
2464+ return
2465+ }
2466+ wv.mu.Lock()
2467+ wv.editableFocusBridgeToken = token
2468+ wv.mu.Unlock()
2469+}
2470+
2471+// matchesEditableFocusBridgeToken compares a candidate token in constant time.
2472+func (wv *WebView) matchesEditableFocusBridgeToken(candidate string) bool {
2473+ if wv == nil || candidate == "" {
2474+ return false
2475+ }
2476+ token := wv.EditableFocusBridgeToken()
2477+ if token == "" {
2478+ return false
2479+ }
2480+ return subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1
2481+}
A internal/infrastructure/webkit/webview_load_changed_test.go +50 -0
@@ -0,0 +1,50 @@
1+package webkit
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHandleLoadChanged_LoadStartedDispatchesOutsideLock(t *testing.T) {
11+ wv := &WebView{uri: "https://example.com/start", title: "start"}
12+ var sawURI string
13+ var sawStateURI string
14+ done := make(chan struct{})
15+ wv.OnEditableFocusChanged = func(editable bool) {
16+ require.False(t, editable)
17+ // Same-goroutine re-entry would deadlock if wv.mu were still held.
18+ sawURI = wv.URI()
19+ state := wv.State()
20+ sawStateURI = state.URI
21+ close(done)
22+ }
23+ var loadEvents []LoadEvent
24+ wv.OnLoadChanged = func(ev LoadEvent) {
25+ loadEvents = append(loadEvents, ev)
26+ }
27+
28+ finished := make(chan struct{})
29+ go func() {
30+ wv.handleLoadChanged(LoadStarted, "https://example.com/next", "next", 0.1)
31+ close(finished)
32+ }()
33+
34+ select {
35+ case <-done:
36+ case <-time.After(2 * time.Second):
37+ t.Fatal("editable focus callback deadlocked re-entering URI/State")
38+ }
39+ select {
40+ case <-finished:
41+ case <-time.After(2 * time.Second):
42+ t.Fatal("handleLoadChanged did not finish")
43+ }
44+
45+ require.Equal(t, "https://example.com/next", sawURI)
46+ require.Equal(t, "https://example.com/next", sawStateURI)
47+ require.Equal(t, []LoadEvent{LoadStarted}, loadEvents)
48+ require.True(t, wv.isLoading)
49+ require.True(t, wv.navigationActive.Load())
50+}
@@ -0,0 +1,50 @@
1+package webkit
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHandleLoadChanged_LoadStartedDispatchesOutsideLock(t *testing.T) {
11+ wv := &WebView{uri: "https://example.com/start", title: "start"}
12+ var sawURI string
13+ var sawStateURI string
14+ done := make(chan struct{})
15+ wv.OnEditableFocusChanged = func(editable bool) {
16+ require.False(t, editable)
17+ // Same-goroutine re-entry would deadlock if wv.mu were still held.
18+ sawURI = wv.URI()
19+ state := wv.State()
20+ sawStateURI = state.URI
21+ close(done)
22+ }
23+ var loadEvents []LoadEvent
24+ wv.OnLoadChanged = func(ev LoadEvent) {
25+ loadEvents = append(loadEvents, ev)
26+ }
27+
28+ finished := make(chan struct{})
29+ go func() {
30+ wv.handleLoadChanged(LoadStarted, "https://example.com/next", "next", 0.1)
31+ close(finished)
32+ }()
33+
34+ select {
35+ case <-done:
36+ case <-time.After(2 * time.Second):
37+ t.Fatal("editable focus callback deadlocked re-entering URI/State")
38+ }
39+ select {
40+ case <-finished:
41+ case <-time.After(2 * time.Second):
42+ t.Fatal("handleLoadChanged did not finish")
43+ }
44+
45+ require.Equal(t, "https://example.com/next", sawURI)
46+ require.Equal(t, "https://example.com/next", sawStateURI)
47+ require.Equal(t, []LoadEvent{LoadStarted}, loadEvents)
48+ require.True(t, wv.isLoading)
49+ require.True(t, wv.navigationActive.Load())
50+}
M internal/infrastructure/webkit/webview_runjavascript_test.go +2 -2
@@ -121,7 +121,7 @@ func TestScrollPage_UsesFallbackDeltasAndRunsJavaScript(t *testing.T) {
121121 gotDX, gotDY = dx, dy
122122 return fmt.Sprintf("scroll(%d,%d)", dx, dy)
123123 }
124- runPageScrollFallbackJS = func(_ *WebView, _ context.Context, script string) {
124+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
125125 runCount++
126126 gotScript = script
127127 }
@@ -172,7 +172,7 @@ func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) {
172172 gotDX, gotDY = dx, dy
173173 return "ok"
174174 }
175- runPageScrollFallbackJS = func(_ *WebView, _ context.Context, script string) {
175+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
176176 assert.Equal(t, "ok", script)
177177 }
178178
@@ -121,7 +121,7 @@ func TestScrollPage_UsesFallbackDeltasAndRunsJavaScript(t *testing.T) {
121 gotDX, gotDY = dx, dy121 gotDX, gotDY = dx, dy
122 return fmt.Sprintf("scroll(%d,%d)", dx, dy)122 return fmt.Sprintf("scroll(%d,%d)", dx, dy)
123 }123 }
124- runPageScrollFallbackJS = func(_ *WebView, _ context.Context, script string) {124+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
125 runCount++125 runCount++
126 gotScript = script126 gotScript = script
127 }127 }
@@ -172,7 +172,7 @@ func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) {
172 gotDX, gotDY = dx, dy172 gotDX, gotDY = dx, dy
173 return "ok"173 return "ok"
174 }174 }
175- runPageScrollFallbackJS = func(_ *WebView, _ context.Context, script string) {175+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
176 assert.Equal(t, "ok", script)176 assert.Equal(t, "ok", script)
177 }177 }
178 178

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