detached 3192764

fix(cef): harden fallback focus and clipboard sync

commit
3192764f7374b495afb78b53b0c1b5e9684f156a
author
brice · · 2026-04-18 11:06 UTC
parent
4042f88
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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(){
385385 }, 1000);
386386 })();`
387387
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.
393393 const clipboardSelectionFetchBridgeJS = `(function(){
394394 if (window.__dumberClipboardBridge) return;
395395 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+
396428 function sendToClipboard(text) {
397429 if (!text) return;
398430 var body = JSON.stringify({text: text});
399- var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
431+ var encoded = encodeBody(body);
400432 if (!encoded) return;
401433 fetch('dumb:///api/clipboard-set', {
402434 method: 'POST',
403435 headers: {'X-Dumber-Body': encoded}
404436 }).catch(function(){});
405437 }
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+
406446 document.addEventListener('copy', function() {
407- var sel = window.getSelection();
408- if (sel) sendToClipboard(sel.toString());
447+ var text = getSelectedText();
448+ if (text) sendToClipboard(text);
409449 });
410450 document.addEventListener('cut', function() {
411- var sel = window.getSelection();
412- if (sel) sendToClipboard(sel.toString());
451+ var text = getSelectedText();
452+ if (text) sendToClipboard(text);
413453 });
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();
414459 })();`
415460
416461 // contentInjector implements port.ContentInjector for the CEF engine.
@@ -533,9 +578,8 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
533578 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
534579 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
535580
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.
539583 wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS)
540584
541585 // 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 the388+// clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389-// selected text back to Go via the message bridge so it can be written to the389+// 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 to390+// regression. It mirrors explicit clipboard text from both DOM selections and
391-// Chromium's internal clipboard. We keep this lightweight shim enabled while391+// 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 can581+ // 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 while582+ // 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 {
419419 }
420420
421421 const (
422- dirPerm = 0o755
423- filePerm = 0o644
422+ dirPerm = 0o700
423+ filePerm = 0o600
424424 )
425425
426426 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 = 0o755422+ dirPerm = 0o700
423- filePerm = 0o644423+ 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 @@
11 package cef
22
33 import (
4+ "os"
45 "path/filepath"
56 "testing"
67
@@ -63,6 +64,18 @@ func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
6364 require.Empty(t, path)
6465 }
6566
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+
6679 func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
6780 t.Setenv(puregoCEFInitTraceEnvVar, "1")
6881 logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
@@ -71,6 +84,14 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
7184 require.NoError(t, err)
7285 require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
7386 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())
7495 }
7596
7697 func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
@@ -1,6 +1,7 @@
1 package cef1 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 {
223223 bridgeHandler purecef.V8Handler
224224 }
225225
226-func newRendererBridgeProcessHandler() purecef.RenderProcessHandler {
227- return &rendererBridgeProcessHandler{bridgeHandler: &rendererBridgeV8Handler{}}
228-}
229-
230226 func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() {
231227 if h == nil {
232228 return
@@ -223,10 +223,6 @@ type rendererBridgeProcessHandler struct {
223 bridgeHandler purecef.V8Handler223 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 return228 return
M internal/infrastructure/cef/scheme_handler.go +17 -4
@@ -172,7 +172,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
172172 return h.handleClipboardSet(request)
173173
174174 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
175- return h.handleFocusSync(browser)
175+ return h.handleFocusSync(request, browser)
176176
177177 default:
178178 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
@@ -249,7 +249,12 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
249249
250250 // handleClipboardSet receives copied text from JS copy/cut events and writes
251251 // 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+)
253258
254259 func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
255260 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
@@ -283,7 +288,15 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
283288 return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
284289 }
285290
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+ }
287300 if browser == nil {
288301 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
289302 return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
@@ -561,7 +574,7 @@ func (rh *staticResourceHandler) Cancel() {}
561574 // The JS bridge base64-encodes the JSON body into this header to avoid the
562575 // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
563576 func readBodyFromHeader(request purecef.Request) []byte {
564- encoded := request.GetHeaderByName("X-Dumber-Body")
577+ encoded := request.GetHeaderByName(dumberBodyHeaderName)
565578 if encoded == "" {
566579 return nil
567580 }
@@ -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 writes250 // 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 MB252+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 the574 // 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 nil579 return nil
567 }580 }
M internal/infrastructure/cef/scheme_handler_test.go +55 -2
@@ -52,7 +52,7 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
5252 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
5353 request := cefmocks.NewMockRequest(t)
5454 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()
5656
5757 body := readBodyFromHeader(request)
5858
@@ -72,7 +72,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
7272
7373 request := cefmocks.NewMockRequest(t)
7474 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()
7676
7777 response := cefmocks.NewMockResponse(t)
7878 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
@@ -87,3 +87,56 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
8787 require.Positive(t, responseLength)
8888 require.Equal(t, "copied from js", copied)
8989 }
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+}

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