fix(webkit): harden editable focus lifecycle
- commit
- c7fcb6dce4e4602a67f449561d9910e438f88be2
- author
- brice · · 2026-07-29 05:04 UTC
- parent
- 3ad27cd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 196 | 196 | let pressedKey = null; | |
| 197 | 197 | ||
| 198 | 198 | function postEditableFocus(editable) { | |
| 199 | + | if (!editableFocusToken) return; | |
| 199 | 200 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | |
| 200 | 201 | window.webkit.messageHandlers.dumber.postMessage({ | |
| 201 | 202 | type: 'editable_focus_changed', | |
| @@ -487,8 +488,10 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 487 | 488 | // 8. Inject accent key detection for all pages and all frames (unconditional). | |
| 488 | 489 | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 489 | 490 | 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 | + | } | |
| 492 | 495 | } | |
| 493 | 496 | addScript( | |
| 494 | 497 | 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 := accentDetectionScript | 490 | 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) { | |||
| 45 | 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 | 58 | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |
| 49 | 59 | script := buildExplicitCopyScript() | |
| 50 | 60 | ||
| @@ -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 | |||
| 311 | 311 | return true | |
| 312 | 312 | } | |
| 313 | 313 | } | |
| 314 | - | if payload.Token == "" || payload.Token != senderWV.editableFocusBridgeToken { | |
| 314 | + | if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) { | |
| 315 | 315 | logging.FromContext(r.baseCtx).Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token") | |
| 316 | 316 | return true | |
| 317 | 317 | } | |
| @@ -311,7 +311,7 @@ func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Me | |||||
| 311 | return true | 311 | 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 true | 316 | return true | ||
| 317 | } | 317 | } | ||
M internal/infrastructure/webkit/messaging_test.go +5 -3
| @@ -60,13 +60,15 @@ func TestHandleAllowlistedEditableFocusMessage_AllowsUntrustedPage(t *testing.T) | |||
| 60 | 60 | func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) { | |
| 61 | 61 | router := NewMessageRouter(context.Background()) | |
| 62 | 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 | 66 | called := false | |
| 64 | 67 | wv.SetCallbacks(&port.WebViewCallbacks{ | |
| 65 | 68 | OnEditableFocusChanged: func(bool) { | |
| 66 | 69 | called = true | |
| 67 | 70 | }, | |
| 68 | 71 | }) | |
| 69 | - | wv.ResetForPoolReuse() | |
| 70 | 72 | ||
| 71 | 73 | handled := router.handleAllowlistedBridgeMessage(wv, Message{ | |
| 72 | 74 | Type: "editable_focus_changed", | |
| @@ -82,8 +84,8 @@ func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) { | |||
| 82 | 84 | ||
| 83 | 85 | wv.ResetForPoolReuse() | |
| 84 | 86 | ||
| 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()) | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 89 | 91 | 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 := false | 66 | called := false | ||
| 64 | wv.SetCallbacks(&port.WebViewCallbacks{ | 67 | wv.SetCallbacks(&port.WebViewCallbacks{ | ||
| 65 | OnEditableFocusChanged: func(bool) { | 68 | OnEditableFocusChanged: func(bool) { | ||
| 66 | called = true | 69 | 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) { | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | |
| 181 | + | alreadyAttached := wv.FrontendAttached() | |
| 181 | 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 | 186 | wv.ucm.RemoveAllScripts() | |
| 184 | 187 | wv.ucm.RemoveAllStyleSheets() | |
| 185 | 188 | p.injector.InjectScripts(ctx, wv.ucm, wv.ID()) | |
| @@ -245,6 +248,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP | |||
| 245 | 248 | 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 | 258 | func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool { | |
| 249 | 259 | return settings != nil && wv != nil && wv.inner != nil | |
| 250 | 260 | } | |
| @@ -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) > 0 | 248 | 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 != nil | 259 | 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) { | |||
| 16 | 16 | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe") | |
| 17 | 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 | + | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/rand" | |
| 6 | + | "crypto/subtle" | |
| 6 | 7 | "encoding/hex" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "net/url" | |
| @@ -40,10 +41,24 @@ var _ port.OAuthCallbackCapable = (*WebView)(nil) | |||
| 40 | 41 | var _ port.PageScrollable = (*WebView)(nil) | |
| 41 | 42 | ||
| 42 | 43 | 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 | 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 | 62 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| 48 | 63 | // Infrastructure layer uses the type defined in the application port. | |
| 49 | 64 | type WebViewID = port.WebViewID | |
| @@ -223,18 +238,8 @@ const ( | |||
| 223 | 238 | runJSNonFatalLogInterval = 30 * time.Second | |
| 224 | 239 | runJSAggregateLogEvery = 20 | |
| 225 | 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 | 243 | type findControllerAdapter struct { | |
| 239 | 244 | fc *webkit.FindController | |
| 240 | 245 | } | |
| @@ -471,38 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 471 | 476 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | |
| 472 | 477 | uri := inner.GetUri() | |
| 473 | 478 | 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) | |
| 501 | 481 | } | |
| 502 | 482 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | |
| 503 | 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 | 522 | func (wv *WebView) connectLoadFailedSignal() { | |
| 507 | 523 | loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool { | |
| 508 | 524 | wv.logger.Warn(). | |
| @@ -2083,7 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||
| 2083 | 2099 | return | |
| 2084 | 2100 | } | |
| 2085 | 2101 | wv.generation.Add(1) | |
| 2086 | - | wv.editableFocusBridgeToken = newEditableFocusBridgeToken() | |
| 2102 | + | wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken()) | |
| 2087 | 2103 | ||
| 2088 | 2104 | // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView. | |
| 2089 | 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 | 2440 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 2425 | 2441 | } | |
| 2426 | 2442 | js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY) | |
| 2427 | - | runPageScrollFallbackJS(wv, ctx, js) | |
| 2443 | + | runPageScrollFallbackJS(ctx, wv, js) | |
| 2428 | 2444 | 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 | + | } | |
| @@ -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.BuildScrollByJS | 43 | 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.WebViewID | 64 | type WebViewID = port.WebViewID | ||
| @@ -223,18 +238,8 @@ const ( | |||||
| 223 | runJSNonFatalLogInterval = 30 * time.Second | 238 | runJSNonFatalLogInterval = 30 * time.Second | ||
| 224 | runJSAggregateLogEvery = 20 | 239 | 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.FindController | 244 | 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 | return | 2099 | 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 nil | 2444 | 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) { | |||
| 121 | 121 | gotDX, gotDY = dx, dy | |
| 122 | 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 | 125 | runCount++ | |
| 126 | 126 | gotScript = script | |
| 127 | 127 | } | |
| @@ -172,7 +172,7 @@ func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) { | |||
| 172 | 172 | gotDX, gotDY = dx, dy | |
| 173 | 173 | return "ok" | |
| 174 | 174 | } | |
| 175 | - | runPageScrollFallbackJS = func(_ *WebView, _ context.Context, script string) { | |
| 175 | + | runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) { | |
| 176 | 176 | assert.Equal(t, "ok", script) | |
| 177 | 177 | } | |
| 178 | 178 | ||
| @@ -121,7 +121,7 @@ func TestScrollPage_UsesFallbackDeltasAndRunsJavaScript(t *testing.T) { | |||||
| 121 | gotDX, gotDY = dx, dy | 121 | 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 = script | 126 | gotScript = script | ||
| 127 | } | 127 | } | ||
| @@ -172,7 +172,7 @@ func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) { | |||||
| 172 | gotDX, gotDY = dx, dy | 172 | 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 | ||||