fix(cef): harden fallback focus and clipboard sync
- commit
- 3192764f7374b495afb78b53b0c1b5e9684f156a
- author
- brice · · 2026-04-18 11:06 UTC
- parent
- 4042f88
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +174 -25
M internal/infrastructure/cef/content_injector.go +57 -13
| @@ -385,32 +385,77 @@ const redditDirectVideoJS = `(function(){ | |||
| 385 | 385 | }, 1000); | |
| 386 | 386 | })();` | |
| 387 | 387 | ||
| 388 | - | // clipboardSelectionFetchBridgeJS intercepts copy/cut events and sends the | |
| 389 | - | // selected text back to Go via the message bridge so it can be written to the | |
| 390 | - | // GDK clipboard. Without this, Ctrl+C in CEF OSR mode writes only to | |
| 391 | - | // Chromium's internal clipboard. We keep this lightweight shim enabled while | |
| 392 | - | // the native renderer bridge is disabled due to an OSR startup regression. | |
| 388 | + | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled | |
| 389 | + | // while the native renderer bridge remains disabled due to an OSR startup | |
| 390 | + | // regression. It mirrors explicit clipboard text from both DOM selections and | |
| 391 | + | // input/textarea selections, and also reasserts browser focus when an editable | |
| 392 | + | // element gains DOM focus. | |
| 393 | 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | |
| 394 | 394 | if (window.__dumberClipboardBridge) return; | |
| 395 | 395 | window.__dumberClipboardBridge = true; | |
| 396 | + | ||
| 397 | + | function encodeBody(body) { | |
| 398 | + | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | function getActiveElementSelection() { | |
| 402 | + | var el = document.activeElement; | |
| 403 | + | if (!el) return ''; | |
| 404 | + | var tag = el.tagName; | |
| 405 | + | if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') { | |
| 406 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |
| 407 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |
| 408 | + | if (end > start) return el.value.slice(start, end); | |
| 409 | + | } | |
| 410 | + | return ''; | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | function getSelectedText() { | |
| 414 | + | var activeSelection = getActiveElementSelection(); | |
| 415 | + | if (activeSelection) return activeSelection; | |
| 416 | + | var sel = window.getSelection(); | |
| 417 | + | return sel ? sel.toString() : ''; | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | function isEditable(node) { | |
| 421 | + | if (!node || node.nodeType !== 1) return false; | |
| 422 | + | if (node.isContentEditable) return true; | |
| 423 | + | var tag = node.tagName; | |
| 424 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false; | |
| 425 | + | return !node.disabled && !node.readOnly; | |
| 426 | + | } | |
| 427 | + | ||
| 396 | 428 | function sendToClipboard(text) { | |
| 397 | 429 | if (!text) return; | |
| 398 | 430 | var body = JSON.stringify({text: text}); | |
| 399 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 431 | + | var encoded = encodeBody(body); | |
| 400 | 432 | if (!encoded) return; | |
| 401 | 433 | fetch('dumb:///api/clipboard-set', { | |
| 402 | 434 | method: 'POST', | |
| 403 | 435 | headers: {'X-Dumber-Body': encoded} | |
| 404 | 436 | }).catch(function(){}); | |
| 405 | 437 | } | |
| 438 | + | ||
| 439 | + | function sendFocusSync() { | |
| 440 | + | fetch('dumb:///api/focus-sync', { | |
| 441 | + | method: 'POST', | |
| 442 | + | headers: {'X-Dumber-Bridge-Action': 'focus-sync'} | |
| 443 | + | }).catch(function(){}); | |
| 444 | + | } | |
| 445 | + | ||
| 406 | 446 | document.addEventListener('copy', function() { | |
| 407 | - | var sel = window.getSelection(); | |
| 408 | - | if (sel) sendToClipboard(sel.toString()); | |
| 447 | + | var text = getSelectedText(); | |
| 448 | + | if (text) sendToClipboard(text); | |
| 409 | 449 | }); | |
| 410 | 450 | document.addEventListener('cut', function() { | |
| 411 | - | var sel = window.getSelection(); | |
| 412 | - | if (sel) sendToClipboard(sel.toString()); | |
| 451 | + | var text = getSelectedText(); | |
| 452 | + | if (text) sendToClipboard(text); | |
| 413 | 453 | }); | |
| 454 | + | document.addEventListener('focusin', function(event) { | |
| 455 | + | if (event && event.isTrusted === false) return; | |
| 456 | + | if (isEditable(event && event.target)) sendFocusSync(); | |
| 457 | + | }, true); | |
| 458 | + | if (isEditable(document.activeElement)) sendFocusSync(); | |
| 414 | 459 | })();` | |
| 415 | 460 | ||
| 416 | 461 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| @@ -533,9 +578,8 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 533 | 578 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 534 | 579 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 535 | 580 | ||
| 536 | - | // Clipboard copy/cut still needs a JS bridge in OSR mode so selected text can | |
| 537 | - | // reach the system clipboard. Keep the lightweight fetch bridge for now while | |
| 538 | - | // the native renderer bridge remains disabled. | |
| 581 | + | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 582 | + | // mode while the native renderer bridge remains disabled. | |
| 539 | 583 | wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS) | |
| 540 | 584 | ||
| 541 | 585 | // Video playback diagnostic — logs video element state changes. | |
| @@ -385,32 +385,77 @@ const redditDirectVideoJS = `(function(){ | |||||
| 385 | }, 1000); | 385 | }, 1000); | ||
| 386 | })();` | 386 | })();` | ||
| 387 | 387 | ||||
| 388 | - | // clipboardSelectionFetchBridgeJS intercepts copy/cut events and sends the | 388 | + | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled |
| 389 | - | // selected text back to Go via the message bridge so it can be written to the | 389 | + | // while the native renderer bridge remains disabled due to an OSR startup |
| 390 | - | // GDK clipboard. Without this, Ctrl+C in CEF OSR mode writes only to | 390 | + | // regression. It mirrors explicit clipboard text from both DOM selections and |
| 391 | - | // Chromium's internal clipboard. We keep this lightweight shim enabled while | 391 | + | // input/textarea selections, and also reasserts browser focus when an editable |
| 392 | - | // the native renderer bridge is disabled due to an OSR startup regression. | 392 | + | // element gains DOM focus. |
| 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | ||
| 394 | if (window.__dumberClipboardBridge) return; | 394 | if (window.__dumberClipboardBridge) return; | ||
| 395 | window.__dumberClipboardBridge = true; | 395 | window.__dumberClipboardBridge = true; | ||
| 396 | + | ||||
| 397 | + | function encodeBody(body) { | |||
| 398 | + | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | function getActiveElementSelection() { | |||
| 402 | + | var el = document.activeElement; | |||
| 403 | + | if (!el) return ''; | |||
| 404 | + | var tag = el.tagName; | |||
| 405 | + | if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') { | |||
| 406 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |||
| 407 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |||
| 408 | + | if (end > start) return el.value.slice(start, end); | |||
| 409 | + | } | |||
| 410 | + | return ''; | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | function getSelectedText() { | |||
| 414 | + | var activeSelection = getActiveElementSelection(); | |||
| 415 | + | if (activeSelection) return activeSelection; | |||
| 416 | + | var sel = window.getSelection(); | |||
| 417 | + | return sel ? sel.toString() : ''; | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | function isEditable(node) { | |||
| 421 | + | if (!node || node.nodeType !== 1) return false; | |||
| 422 | + | if (node.isContentEditable) return true; | |||
| 423 | + | var tag = node.tagName; | |||
| 424 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false; | |||
| 425 | + | return !node.disabled && !node.readOnly; | |||
| 426 | + | } | |||
| 427 | + | ||||
| 396 | function sendToClipboard(text) { | 428 | function sendToClipboard(text) { | ||
| 397 | if (!text) return; | 429 | if (!text) return; | ||
| 398 | var body = JSON.stringify({text: text}); | 430 | var body = JSON.stringify({text: text}); | ||
| 399 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | 431 | + | var encoded = encodeBody(body); |
| 400 | if (!encoded) return; | 432 | if (!encoded) return; | ||
| 401 | fetch('dumb:///api/clipboard-set', { | 433 | fetch('dumb:///api/clipboard-set', { | ||
| 402 | method: 'POST', | 434 | method: 'POST', | ||
| 403 | headers: {'X-Dumber-Body': encoded} | 435 | headers: {'X-Dumber-Body': encoded} | ||
| 404 | }).catch(function(){}); | 436 | }).catch(function(){}); | ||
| 405 | } | 437 | } | ||
| 438 | + | ||||
| 439 | + | function sendFocusSync() { | |||
| 440 | + | fetch('dumb:///api/focus-sync', { | |||
| 441 | + | method: 'POST', | |||
| 442 | + | headers: {'X-Dumber-Bridge-Action': 'focus-sync'} | |||
| 443 | + | }).catch(function(){}); | |||
| 444 | + | } | |||
| 445 | + | ||||
| 406 | document.addEventListener('copy', function() { | 446 | document.addEventListener('copy', function() { | ||
| 407 | - | var sel = window.getSelection(); | 447 | + | var text = getSelectedText(); |
| 408 | - | if (sel) sendToClipboard(sel.toString()); | 448 | + | if (text) sendToClipboard(text); |
| 409 | }); | 449 | }); | ||
| 410 | document.addEventListener('cut', function() { | 450 | document.addEventListener('cut', function() { | ||
| 411 | - | var sel = window.getSelection(); | 451 | + | var text = getSelectedText(); |
| 412 | - | if (sel) sendToClipboard(sel.toString()); | 452 | + | if (text) sendToClipboard(text); |
| 413 | }); | 453 | }); | ||
| 454 | + | document.addEventListener('focusin', function(event) { | |||
| 455 | + | if (event && event.isTrusted === false) return; | |||
| 456 | + | if (isEditable(event && event.target)) sendFocusSync(); | |||
| 457 | + | }, true); | |||
| 458 | + | if (isEditable(document.activeElement)) sendFocusSync(); | |||
| 414 | })();` | 459 | })();` | ||
| 415 | 460 | ||||
| 416 | // contentInjector implements port.ContentInjector for the CEF engine. | 461 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| @@ -533,9 +578,8 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 533 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | 578 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | ||
| 534 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 579 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 535 | 580 | ||||
| 536 | - | // Clipboard copy/cut still needs a JS bridge in OSR mode so selected text can | 581 | + | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR |
| 537 | - | // reach the system clipboard. Keep the lightweight fetch bridge for now while | 582 | + | // mode while the native renderer bridge remains disabled. |
| 538 | - | // the native renderer bridge remains disabled. | |||
| 539 | wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS) | 583 | wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS) | ||
| 540 | 584 | ||||
| 541 | // Video playback diagnostic — logs video element state changes. | 585 | // Video playback diagnostic — logs video element state changes. | ||
A internal/infrastructure/cef/content_injector_test.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) { | |
| 10 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement") | |
| 11 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionStart") | |
| 12 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionEnd") | |
| 13 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "window.getSelection()") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) { | |
| 17 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "dumb:///api/focus-sync") | |
| 18 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action") | |
| 19 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync") | |
| 20 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin") | |
| 21 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "if (isEditable(document.activeElement)) sendFocusSync();") | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) { | |||
| 10 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement") | |||
| 11 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionStart") | |||
| 12 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionEnd") | |||
| 13 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "window.getSelection()") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) { | |||
| 17 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "dumb:///api/focus-sync") | |||
| 18 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action") | |||
| 19 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync") | |||
| 20 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin") | |||
| 21 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "if (isEditable(document.activeElement)) sendFocusSync();") | |||
| 22 | + | } | |||
M internal/infrastructure/cef/engine_init.go +2 -2
| @@ -419,8 +419,8 @@ func findHelperBinary() string { | |||
| 419 | 419 | } | |
| 420 | 420 | ||
| 421 | 421 | const ( | |
| 422 | - | dirPerm = 0o755 | |
| 423 | - | filePerm = 0o644 | |
| 422 | + | dirPerm = 0o700 | |
| 423 | + | filePerm = 0o600 | |
| 424 | 424 | ) | |
| 425 | 425 | ||
| 426 | 426 | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| @@ -419,8 +419,8 @@ func findHelperBinary() string { | |||||
| 419 | } | 419 | } | ||
| 420 | 420 | ||||
| 421 | const ( | 421 | const ( | ||
| 422 | - | dirPerm = 0o755 | 422 | + | dirPerm = 0o700 |
| 423 | - | filePerm = 0o644 | 423 | + | filePerm = 0o600 |
| 424 | ) | 424 | ) | ||
| 425 | 425 | ||||
| 426 | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | 426 | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | ||
M internal/infrastructure/cef/engine_init_test.go +21 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -63,6 +64,18 @@ func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |||
| 63 | 64 | require.Empty(t, path) | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 67 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { | |
| 68 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 69 | + | ||
| 70 | + | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | require.Equal(t, logFile, path) | |
| 73 | + | ||
| 74 | + | info, err := os.Stat(filepath.Dir(logFile)) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm()) | |
| 77 | + | } | |
| 78 | + | ||
| 66 | 79 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |
| 67 | 80 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 68 | 81 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | |
| @@ -71,6 +84,14 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |||
| 71 | 84 | require.NoError(t, err) | |
| 72 | 85 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |
| 73 | 86 | require.FileExists(t, path) | |
| 87 | + | ||
| 88 | + | info, err := os.Stat(path) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Equal(t, os.FileMode(filePerm), info.Mode().Perm()) | |
| 91 | + | ||
| 92 | + | dirInfo, err := os.Stat(filepath.Dir(path)) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |
| 74 | 95 | } | |
| 75 | 96 | ||
| 76 | 97 | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "path/filepath" | 5 | "path/filepath" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -63,6 +64,18 @@ func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |||||
| 63 | require.Empty(t, path) | 64 | require.Empty(t, path) | ||
| 64 | } | 65 | } | ||
| 65 | 66 | ||||
| 67 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { | |||
| 68 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |||
| 69 | + | ||||
| 70 | + | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | require.Equal(t, logFile, path) | |||
| 73 | + | ||||
| 74 | + | info, err := os.Stat(filepath.Dir(logFile)) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm()) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 66 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | 79 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | ||
| 67 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | 80 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | ||
| 68 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | 81 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | ||
| @@ -71,6 +84,14 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |||||
| 71 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 72 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | 85 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | ||
| 73 | require.FileExists(t, path) | 86 | require.FileExists(t, path) | ||
| 87 | + | ||||
| 88 | + | info, err := os.Stat(path) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.Equal(t, os.FileMode(filePerm), info.Mode().Perm()) | |||
| 91 | + | ||||
| 92 | + | dirInfo, err := os.Stat(filepath.Dir(path)) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |||
| 74 | } | 95 | } | ||
| 75 | 96 | ||||
| 76 | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | 97 | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | ||
M internal/infrastructure/cef/renderer_bridge.go +0 -4
| @@ -223,10 +223,6 @@ type rendererBridgeProcessHandler struct { | |||
| 223 | 223 | bridgeHandler purecef.V8Handler | |
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | - | func newRendererBridgeProcessHandler() purecef.RenderProcessHandler { | |
| 227 | - | return &rendererBridgeProcessHandler{bridgeHandler: &rendererBridgeV8Handler{}} | |
| 228 | - | } | |
| 229 | - | ||
| 230 | 226 | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | |
| 231 | 227 | if h == nil { | |
| 232 | 228 | return | |
| @@ -223,10 +223,6 @@ type rendererBridgeProcessHandler struct { | |||||
| 223 | bridgeHandler purecef.V8Handler | 223 | bridgeHandler purecef.V8Handler | ||
| 224 | } | 224 | } | ||
| 225 | 225 | ||||
| 226 | - | func newRendererBridgeProcessHandler() purecef.RenderProcessHandler { | |||
| 227 | - | return &rendererBridgeProcessHandler{bridgeHandler: &rendererBridgeV8Handler{}} | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | 226 | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | ||
| 231 | if h == nil { | 227 | if h == nil { | ||
| 232 | return | 228 | return | ||
M internal/infrastructure/cef/scheme_handler.go +17 -4
| @@ -172,7 +172,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||
| 172 | 172 | return h.handleClipboardSet(request) | |
| 173 | 173 | ||
| 174 | 174 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 175 | - | return h.handleFocusSync(browser) | |
| 175 | + | return h.handleFocusSync(request, browser) | |
| 176 | 176 | ||
| 177 | 177 | default: | |
| 178 | 178 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| @@ -249,7 +249,12 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||
| 249 | 249 | ||
| 250 | 250 | // handleClipboardSet receives copied text from JS copy/cut events and writes | |
| 251 | 251 | // it to the system clipboard via the engine callback. | |
| 252 | - | const maxClipboardBytes = 10 << 20 // 10 MB | |
| 252 | + | const ( | |
| 253 | + | maxClipboardBytes = 10 << 20 // 10 MB | |
| 254 | + | dumberBodyHeaderName = "X-Dumber-Body" | |
| 255 | + | dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action" | |
| 256 | + | dumberBridgeActionFocusSync = "focus-sync" | |
| 257 | + | ) | |
| 253 | 258 | ||
| 254 | 259 | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | |
| 255 | 260 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | |
| @@ -283,7 +288,15 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||
| 283 | 288 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 284 | 289 | } | |
| 285 | 290 | ||
| 286 | - | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | |
| 291 | + | func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 292 | + | bridgeAction := "" | |
| 293 | + | if request != nil { | |
| 294 | + | bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName)) | |
| 295 | + | } | |
| 296 | + | if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) { | |
| 297 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header") | |
| 298 | + | return h.newJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 299 | + | } | |
| 287 | 300 | if browser == nil { | |
| 288 | 301 | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |
| 289 | 302 | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |
| @@ -561,7 +574,7 @@ func (rh *staticResourceHandler) Cancel() {} | |||
| 561 | 574 | // The JS bridge base64-encodes the JSON body into this header to avoid the | |
| 562 | 575 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | |
| 563 | 576 | func readBodyFromHeader(request purecef.Request) []byte { | |
| 564 | - | encoded := request.GetHeaderByName("X-Dumber-Body") | |
| 577 | + | encoded := request.GetHeaderByName(dumberBodyHeaderName) | |
| 565 | 578 | if encoded == "" { | |
| 566 | 579 | return nil | |
| 567 | 580 | } | |
| @@ -172,7 +172,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||||
| 172 | return h.handleClipboardSet(request) | 172 | return h.handleClipboardSet(request) | ||
| 173 | 173 | ||||
| 174 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | 174 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | ||
| 175 | - | return h.handleFocusSync(browser) | 175 | + | return h.handleFocusSync(request, browser) |
| 176 | 176 | ||||
| 177 | default: | 177 | default: | ||
| 178 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | 178 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | ||
| @@ -249,7 +249,12 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||||
| 249 | 249 | ||||
| 250 | // handleClipboardSet receives copied text from JS copy/cut events and writes | 250 | // handleClipboardSet receives copied text from JS copy/cut events and writes | ||
| 251 | // it to the system clipboard via the engine callback. | 251 | // it to the system clipboard via the engine callback. | ||
| 252 | - | const maxClipboardBytes = 10 << 20 // 10 MB | 252 | + | const ( |
| 253 | + | maxClipboardBytes = 10 << 20 // 10 MB | |||
| 254 | + | dumberBodyHeaderName = "X-Dumber-Body" | |||
| 255 | + | dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action" | |||
| 256 | + | dumberBridgeActionFocusSync = "focus-sync" | |||
| 257 | + | ) | |||
| 253 | 258 | ||||
| 254 | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | 259 | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | ||
| 255 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | 260 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | ||
| @@ -283,7 +288,15 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||||
| 283 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | 288 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | ||
| 284 | } | 289 | } | ||
| 285 | 290 | ||||
| 286 | - | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | 291 | + | func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { |
| 292 | + | bridgeAction := "" | |||
| 293 | + | if request != nil { | |||
| 294 | + | bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName)) | |||
| 295 | + | } | |||
| 296 | + | if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) { | |||
| 297 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header") | |||
| 298 | + | return h.newJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 299 | + | } | |||
| 287 | if browser == nil { | 300 | if browser == nil { | ||
| 288 | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | 301 | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | ||
| 289 | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | 302 | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | ||
| @@ -561,7 +574,7 @@ func (rh *staticResourceHandler) Cancel() {} | |||||
| 561 | // The JS bridge base64-encodes the JSON body into this header to avoid the | 574 | // The JS bridge base64-encodes the JSON body into this header to avoid the | ||
| 562 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | 575 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | ||
| 563 | func readBodyFromHeader(request purecef.Request) []byte { | 576 | func readBodyFromHeader(request purecef.Request) []byte { | ||
| 564 | - | encoded := request.GetHeaderByName("X-Dumber-Body") | 577 | + | encoded := request.GetHeaderByName(dumberBodyHeaderName) |
| 565 | if encoded == "" { | 578 | if encoded == "" { | ||
| 566 | return nil | 579 | return nil | ||
| 567 | } | 580 | } | ||
M internal/infrastructure/cef/scheme_handler_test.go +55 -2
| @@ -52,7 +52,7 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||
| 52 | 52 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 53 | 53 | request := cefmocks.NewMockRequest(t) | |
| 54 | 54 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 55 | - | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | |
| 55 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 56 | 56 | ||
| 57 | 57 | body := readBodyFromHeader(request) | |
| 58 | 58 | ||
| @@ -72,7 +72,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 72 | 72 | ||
| 73 | 73 | request := cefmocks.NewMockRequest(t) | |
| 74 | 74 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 75 | - | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | |
| 75 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 76 | 76 | ||
| 77 | 77 | response := cefmocks.NewMockResponse(t) | |
| 78 | 78 | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| @@ -87,3 +87,56 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 87 | 87 | require.Positive(t, responseLength) | |
| 88 | 88 | require.Equal(t, "copied from js", copied) | |
| 89 | 89 | } | |
| 90 | + | ||
| 91 | + | func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *testing.T) { | |
| 92 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 93 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 94 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 95 | + | ||
| 96 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | ||
| 99 | + | request := cefmocks.NewMockRequest(t) | |
| 100 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | |
| 101 | + | ||
| 102 | + | response := cefmocks.NewMockResponse(t) | |
| 103 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |
| 104 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |
| 105 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 106 | + | ||
| 107 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request) | |
| 108 | + | require.NotNil(t, handler) | |
| 109 | + | ||
| 110 | + | var responseLength int64 | |
| 111 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |
| 112 | + | require.Positive(t, responseLength) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |
| 116 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 117 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 118 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 119 | + | ||
| 120 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | ||
| 123 | + | browser := cefmocks.NewMockBrowser(t) | |
| 124 | + | var focused purecef.Browser | |
| 125 | + | h.onEditableFocus = func(got purecef.Browser) { focused = got } | |
| 126 | + | ||
| 127 | + | request := cefmocks.NewMockRequest(t) | |
| 128 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once() | |
| 129 | + | ||
| 130 | + | response := cefmocks.NewMockResponse(t) | |
| 131 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 132 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 133 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 134 | + | ||
| 135 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request) | |
| 136 | + | require.NotNil(t, handler) | |
| 137 | + | ||
| 138 | + | var responseLength int64 | |
| 139 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |
| 140 | + | require.Positive(t, responseLength) | |
| 141 | + | require.Same(t, browser, focused) | |
| 142 | + | } | |
| @@ -52,7 +52,7 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||||
| 52 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | 52 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | ||
| 53 | request := cefmocks.NewMockRequest(t) | 53 | request := cefmocks.NewMockRequest(t) | ||
| 54 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 54 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||
| 55 | - | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | 55 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() |
| 56 | 56 | ||||
| 57 | body := readBodyFromHeader(request) | 57 | body := readBodyFromHeader(request) | ||
| 58 | 58 | ||||
| @@ -72,7 +72,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||||
| 72 | 72 | ||||
| 73 | request := cefmocks.NewMockRequest(t) | 73 | request := cefmocks.NewMockRequest(t) | ||
| 74 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 74 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||
| 75 | - | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | 75 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() |
| 76 | 76 | ||||
| 77 | response := cefmocks.NewMockResponse(t) | 77 | response := cefmocks.NewMockResponse(t) | ||
| 78 | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | 78 | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | ||
| @@ -87,3 +87,56 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||||
| 87 | require.Positive(t, responseLength) | 87 | require.Positive(t, responseLength) | ||
| 88 | require.Equal(t, "copied from js", copied) | 88 | require.Equal(t, "copied from js", copied) | ||
| 89 | } | 89 | } | ||
| 90 | + | ||||
| 91 | + | func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *testing.T) { | |||
| 92 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 93 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 94 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 95 | + | ||||
| 96 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | ||||
| 99 | + | request := cefmocks.NewMockRequest(t) | |||
| 100 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | |||
| 101 | + | ||||
| 102 | + | response := cefmocks.NewMockResponse(t) | |||
| 103 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 104 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 105 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 106 | + | ||||
| 107 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request) | |||
| 108 | + | require.NotNil(t, handler) | |||
| 109 | + | ||||
| 110 | + | var responseLength int64 | |||
| 111 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 112 | + | require.Positive(t, responseLength) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||
| 116 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 117 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 118 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 119 | + | ||||
| 120 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | ||||
| 123 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 124 | + | var focused purecef.Browser | |||
| 125 | + | h.onEditableFocus = func(got purecef.Browser) { focused = got } | |||
| 126 | + | ||||
| 127 | + | request := cefmocks.NewMockRequest(t) | |||
| 128 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once() | |||
| 129 | + | ||||
| 130 | + | response := cefmocks.NewMockResponse(t) | |||
| 131 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 132 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 133 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 134 | + | ||||
| 135 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request) | |||
| 136 | + | require.NotNil(t, handler) | |||
| 137 | + | ||||
| 138 | + | var responseLength int64 | |||
| 139 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 140 | + | require.Positive(t, responseLength) | |||
| 141 | + | require.Same(t, browser, focused) | |||
| 142 | + | } | |||