detached 4042f88

fix(cef): restore OSR startup paints

commit
4042f88bacdf874671d37d2096b632f1995812c2
author
brice · · 2026-04-18 10:45 UTC
parent
72b6970
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +142 -21
M internal/infrastructure/cef/app.go +7 -8
@@ -45,7 +45,6 @@ func configureCommandLine(commandLine purecef.CommandLine) {
4545 type dumberApp struct {
4646 engine *Engine
4747 bph purecef.BrowserProcessHandler
48- rph purecef.RenderProcessHandler
4948 }
5049
5150 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -54,15 +53,15 @@ type dumberApp struct {
5453 func newDumberApp(engine *Engine) purecef.App {
5554 app := &dumberApp{engine: engine}
5655 app.bph = &dumberBPH{engine: engine}
57- app.rph = newRendererBridgeProcessHandler()
5856 return app
5957 }
6058
6159 // NewSubprocessApp returns a lightweight raw App implementation for helper
62-// processes so cef_execute_process can wrap it itself. Do not pre-wrap with
63-// purecef.NewApp here: helper subprocess startup happens before full cef.Init.
60+// processes so cef_execute_process can wrap it itself. Keep helper processes
61+// free of custom render handlers for now: the renderer bridge introduced a
62+// startup regression where OSR pages stopped producing their first paint.
6463 func NewSubprocessApp() purecef.App {
65- return &subprocessApp{rph: newRendererBridgeProcessHandler()}
64+ return &subprocessApp{}
6665 }
6766
6867 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -92,9 +91,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
9291 }
9392 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
9493 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
95-func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }
94+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
9695
97-type subprocessApp struct{ rph purecef.RenderProcessHandler }
96+type subprocessApp struct{}
9897
9998 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
10099 configureCommandLine(commandLine)
@@ -104,7 +103,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
104103 }
105104 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
106105 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
107-func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }
106+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
108107
109108 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
110109 // child process launch tracking, and diagnostic logging.
@@ -45,7 +45,6 @@ func configureCommandLine(commandLine purecef.CommandLine) {
45 type dumberApp struct {45 type dumberApp struct {
46 engine *Engine46 engine *Engine
47 bph purecef.BrowserProcessHandler47 bph purecef.BrowserProcessHandler
48- rph purecef.RenderProcessHandler
49 }48 }
50 49
51 // newDumberApp creates an App whose BrowserProcessHandler handles context50 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -54,15 +53,15 @@ type dumberApp struct {
54 func newDumberApp(engine *Engine) purecef.App {53 func newDumberApp(engine *Engine) purecef.App {
55 app := &dumberApp{engine: engine}54 app := &dumberApp{engine: engine}
56 app.bph = &dumberBPH{engine: engine}55 app.bph = &dumberBPH{engine: engine}
57- app.rph = newRendererBridgeProcessHandler()
58 return app56 return app
59 }57 }
60 58
61 // NewSubprocessApp returns a lightweight raw App implementation for helper59 // NewSubprocessApp returns a lightweight raw App implementation for helper
62-// processes so cef_execute_process can wrap it itself. Do not pre-wrap with60+// processes so cef_execute_process can wrap it itself. Keep helper processes
63-// purecef.NewApp here: helper subprocess startup happens before full cef.Init.61+// free of custom render handlers for now: the renderer bridge introduced a
62+// startup regression where OSR pages stopped producing their first paint.
64 func NewSubprocessApp() purecef.App {63 func NewSubprocessApp() purecef.App {
65- return &subprocessApp{rph: newRendererBridgeProcessHandler()}64+ return &subprocessApp{}
66 }65 }
67 66
68 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.67 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -92,9 +91,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
92 }91 }
93 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }92 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
94 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }93 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
95-func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }94+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
96 95
97-type subprocessApp struct{ rph purecef.RenderProcessHandler }96+type subprocessApp struct{}
98 97
99 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {98 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
100 configureCommandLine(commandLine)99 configureCommandLine(commandLine)
@@ -104,7 +103,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
104 }103 }
105 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }104 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
106 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }105 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
107-func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph }106+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
108 107
109 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,108 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
110 // child process launch tracking, and diagnostic logging.109 // child process launch tracking, and diagnostic logging.
M internal/infrastructure/cef/content_injector.go +32 -3
@@ -385,6 +385,34 @@ 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.
393+const clipboardSelectionFetchBridgeJS = `(function(){
394+ if (window.__dumberClipboardBridge) return;
395+ window.__dumberClipboardBridge = true;
396+ function sendToClipboard(text) {
397+ if (!text) return;
398+ var body = JSON.stringify({text: text});
399+ var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
400+ if (!encoded) return;
401+ fetch('dumb:///api/clipboard-set', {
402+ method: 'POST',
403+ headers: {'X-Dumber-Body': encoded}
404+ }).catch(function(){});
405+ }
406+ document.addEventListener('copy', function() {
407+ var sel = window.getSelection();
408+ if (sel) sendToClipboard(sel.toString());
409+ });
410+ document.addEventListener('cut', function() {
411+ var sel = window.getSelection();
412+ if (sel) sendToClipboard(sel.toString());
413+ });
414+})();`
415+
388416 // contentInjector implements port.ContentInjector for the CEF engine.
389417 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
390418 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -505,9 +533,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
505533 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
506534 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
507535
508- // Focus sync, selection auto-copy, and clipboard API bridging are installed by
509- // the native render-process bridge at V8 context creation time. Do not inject
510- // fetch-based shims here: external site CSP can block them.
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.
539+ wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS)
511540
512541 // Video playback diagnostic — logs video element state changes.
513542 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
@@ -385,6 +385,34 @@ const redditDirectVideoJS = `(function(){
385 }, 1000);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.
393+const clipboardSelectionFetchBridgeJS = `(function(){
394+ if (window.__dumberClipboardBridge) return;
395+ window.__dumberClipboardBridge = true;
396+ function sendToClipboard(text) {
397+ if (!text) return;
398+ var body = JSON.stringify({text: text});
399+ var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
400+ if (!encoded) return;
401+ fetch('dumb:///api/clipboard-set', {
402+ method: 'POST',
403+ headers: {'X-Dumber-Body': encoded}
404+ }).catch(function(){});
405+ }
406+ document.addEventListener('copy', function() {
407+ var sel = window.getSelection();
408+ if (sel) sendToClipboard(sel.toString());
409+ });
410+ document.addEventListener('cut', function() {
411+ var sel = window.getSelection();
412+ if (sel) sendToClipboard(sel.toString());
413+ });
414+})();`
415+
388 // contentInjector implements port.ContentInjector for the CEF engine.416 // contentInjector implements port.ContentInjector for the CEF engine.
389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.417 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd418 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -505,9 +533,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
505 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)533 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
506 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)534 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
507 535
508- // Focus sync, selection auto-copy, and clipboard API bridging are installed by536+ // Clipboard copy/cut still needs a JS bridge in OSR mode so selected text can
509- // the native render-process bridge at V8 context creation time. Do not inject537+ // reach the system clipboard. Keep the lightweight fetch bridge for now while
510- // fetch-based shims here: external site CSP can block them.538+ // the native renderer bridge remains disabled.
539+ wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS)
511 540
512 // Video playback diagnostic — logs video element state changes.541 // Video playback diagnostic — logs video element state changes.
513 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.542 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
M internal/infrastructure/cef/engine_init.go +20 -0
@@ -11,6 +11,7 @@ import (
1111
1212 purecef "github.com/bnema/purego-cef/cef"
1313 "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/glib"
1415 "github.com/rs/zerolog"
1516
1617 "github.com/bnema/dumber/assets"
@@ -236,6 +237,25 @@ func wireEngine(
236237 }
237238 schemeHandler.setAssets(assets.WebUIAssets)
238239
240+ // Bridge clipboard writes from page JS → GDK system clipboard.
241+ // The callback is invoked on the CEF IO thread, so schedule the actual GDK
242+ // write on the GTK main loop.
243+ schemeHandler.onClipboardSet = func(text string) {
244+ fn := glib.SourceOnceFunc(func(_ uintptr) {
245+ if display := gdk.DisplayGetDefault(); display != nil {
246+ if cb := display.GetClipboard(); cb != nil {
247+ cb.SetText(text)
248+ logger.Debug().Msg("cef: clipboard set via GDK")
249+ } else {
250+ logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
251+ }
252+ } else {
253+ logger.Debug().Msg("cef: clipboard set failed — no GDK display")
254+ }
255+ })
256+ glib.IdleAddOnce(&fn, 0)
257+ }
258+
239259 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
240260
241261 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -11,6 +11,7 @@ import (
11 11
12 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/glib"
14 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
15 16
16 "github.com/bnema/dumber/assets"17 "github.com/bnema/dumber/assets"
@@ -236,6 +237,25 @@ func wireEngine(
236 }237 }
237 schemeHandler.setAssets(assets.WebUIAssets)238 schemeHandler.setAssets(assets.WebUIAssets)
238 239
240+ // Bridge clipboard writes from page JS → GDK system clipboard.
241+ // The callback is invoked on the CEF IO thread, so schedule the actual GDK
242+ // write on the GTK main loop.
243+ schemeHandler.onClipboardSet = func(text string) {
244+ fn := glib.SourceOnceFunc(func(_ uintptr) {
245+ if display := gdk.DisplayGetDefault(); display != nil {
246+ if cb := display.GetClipboard(); cb != nil {
247+ cb.SetText(text)
248+ logger.Debug().Msg("cef: clipboard set via GDK")
249+ } else {
250+ logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
251+ }
252+ } else {
253+ logger.Debug().Msg("cef: clipboard set failed — no GDK display")
254+ }
255+ })
256+ glib.IdleAddOnce(&fn, 0)
257+ }
258+
239 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge259 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
240 260
241 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)261 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
M internal/infrastructure/cef/factory.go +10 -5
@@ -178,10 +178,12 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
178178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
179179 return
180180 }
181- initialURL := wv.pendingNavigationURI()
182- if initialURL == "" {
183- initialURL = "about:blank"
184- }
181+ pendingURL := wv.pendingNavigationURI()
182+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
183+ // the target URL can race host visibility/focus setup and strand the
184+ // renderer without an initial paint. We replay pending navigation from
185+ // OnAfterCreated once the host is fully wired.
186+ initialURL := "about:blank"
185187 result := purecef.BrowserHostCreateBrowser(
186188 pc.windowInfo,
187189 pc.client,
@@ -190,7 +192,10 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
190192 nil, // extraInfo
191193 nil, // requestContext
192194 )
193- log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL")
195+ log.Debug().
196+ Str("initial_url", initialURL).
197+ Str("pending_url", pendingURL).
198+ Msg("cef: BrowserHostCreateBrowser initial URL")
194199 if f.engine != nil {
195200 f.engine.recordBrowserCreateRequest(w, h, result)
196201 }
@@ -178,10 +178,12 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
179 return179 return
180 }180 }
181- initialURL := wv.pendingNavigationURI()181+ pendingURL := wv.pendingNavigationURI()
182- if initialURL == "" {182+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
183- initialURL = "about:blank"183+ // the target URL can race host visibility/focus setup and strand the
184- }184+ // renderer without an initial paint. We replay pending navigation from
185+ // OnAfterCreated once the host is fully wired.
186+ initialURL := "about:blank"
185 result := purecef.BrowserHostCreateBrowser(187 result := purecef.BrowserHostCreateBrowser(
186 pc.windowInfo,188 pc.windowInfo,
187 pc.client,189 pc.client,
@@ -190,7 +192,10 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
190 nil, // extraInfo192 nil, // extraInfo
191 nil, // requestContext193 nil, // requestContext
192 )194 )
193- log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL")195+ log.Debug().
196+ Str("initial_url", initialURL).
197+ Str("pending_url", pendingURL).
198+ Msg("cef: BrowserHostCreateBrowser initial URL")
194 if f.engine != nil {199 if f.engine != nil {
195 f.engine.recordBrowserCreateRequest(w, h, result)200 f.engine.recordBrowserCreateRequest(w, h, result)
196 }201 }
M internal/infrastructure/cef/scheme_handler.go +43 -0
@@ -58,6 +58,10 @@ type dumbSchemeHandler struct {
5858 defaultConfigPayload func() ([]byte, error)
5959 mu sync.RWMutex
6060
61+ // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
62+ // Set by the engine to write to the GDK system clipboard.
63+ onClipboardSet func(text string)
64+
6165 // onEditableFocus is called when JS reports that an editable element gained
6266 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
6367 onEditableFocus func(browser purecef.Browser)
@@ -164,6 +168,9 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
164168 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
165169 return h.handleTranscodeAPI(request)
166170
171+ case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
172+ return h.handleClipboardSet(request)
173+
167174 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
168175 return h.handleFocusSync(browser)
169176
@@ -240,6 +247,42 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
240247 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
241248 }
242249
250+// handleClipboardSet receives copied text from JS copy/cut events and writes
251+// it to the system clipboard via the engine callback.
252+const maxClipboardBytes = 10 << 20 // 10 MB
253+
254+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
255+ h.logger.Debug().Msg("cef: /api/clipboard-set request received")
256+
257+ body := readBodyFromHeader(request)
258+ if body == nil {
259+ h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
260+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
261+ }
262+ if len(body) > maxClipboardBytes {
263+ h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
264+ return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
265+ }
266+
267+ var payload struct {
268+ Text string `json:"text"`
269+ }
270+ if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
271+ h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
272+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
273+ }
274+
275+ h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
276+
277+ if h.onClipboardSet != nil {
278+ h.onClipboardSet(payload.Text)
279+ } else {
280+ h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
281+ }
282+
283+ return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
284+}
285+
243286 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {
244287 if browser == nil {
245288 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
@@ -58,6 +58,10 @@ type dumbSchemeHandler struct {
58 defaultConfigPayload func() ([]byte, error)58 defaultConfigPayload func() ([]byte, error)
59 mu sync.RWMutex59 mu sync.RWMutex
60 60
61+ // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
62+ // Set by the engine to write to the GDK system clipboard.
63+ onClipboardSet func(text string)
64+
61 // onEditableFocus is called when JS reports that an editable element gained65 // onEditableFocus is called when JS reports that an editable element gained
62 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
63 onEditableFocus func(browser purecef.Browser)67 onEditableFocus func(browser purecef.Browser)
@@ -164,6 +168,9 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
164 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):168 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
165 return h.handleTranscodeAPI(request)169 return h.handleTranscodeAPI(request)
166 170
171+ case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
172+ return h.handleClipboardSet(request)
173+
167 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):174 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
168 return h.handleFocusSync(browser)175 return h.handleFocusSync(browser)
169 176
@@ -240,6 +247,42 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
240 return h.newRawResourceHandler(http.StatusOK, "application/json", data)247 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
241 }248 }
242 249
250+// handleClipboardSet receives copied text from JS copy/cut events and writes
251+// it to the system clipboard via the engine callback.
252+const maxClipboardBytes = 10 << 20 // 10 MB
253+
254+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
255+ h.logger.Debug().Msg("cef: /api/clipboard-set request received")
256+
257+ body := readBodyFromHeader(request)
258+ if body == nil {
259+ h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
260+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
261+ }
262+ if len(body) > maxClipboardBytes {
263+ h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
264+ return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
265+ }
266+
267+ var payload struct {
268+ Text string `json:"text"`
269+ }
270+ if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
271+ h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
272+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
273+ }
274+
275+ h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
276+
277+ if h.onClipboardSet != nil {
278+ h.onClipboardSet(payload.Text)
279+ } else {
280+ h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
281+ }
282+
283+ return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
284+}
285+
243 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {286 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {
244 if browser == nil {287 if browser == nil {
245 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")288 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
M internal/infrastructure/cef/scheme_handler_test.go +12 -4
@@ -59,7 +59,7 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
5959 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
6060 }
6161
62-func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {
62+func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
6363 oldNewResourceHandler := cefNewResourceHandler
6464 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
6565 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
@@ -67,15 +67,23 @@ func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {
6767 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
6868 require.NoError(t, err)
6969
70+ var copied string
71+ h.onClipboardSet = func(text string) { copied = text }
72+
73+ request := cefmocks.NewMockRequest(t)
74+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
75+ request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once()
76+
7077 response := cefmocks.NewMockResponse(t)
71- response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
72- response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
78+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
79+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
7380 response.EXPECT().SetMimeType("application/json").Once()
7481
75- handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil)
82+ handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)
7683 require.NotNil(t, handler)
7784
7885 var responseLength int64
7986 handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
8087 require.Positive(t, responseLength)
88+ require.Equal(t, "copied from js", copied)
8189 }
@@ -59,7 +59,7 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
59 require.JSONEq(t, `{"text":"copied from js"}`, string(body))59 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
60 }60 }
61 61
62-func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {62+func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
63 oldNewResourceHandler := cefNewResourceHandler63 oldNewResourceHandler := cefNewResourceHandler
64 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }64 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
65 defer func() { cefNewResourceHandler = oldNewResourceHandler }()65 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
@@ -67,15 +67,23 @@ func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {
67 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })67 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
68 require.NoError(t, err)68 require.NoError(t, err)
69 69
70+ var copied string
71+ h.onClipboardSet = func(text string) { copied = text }
72+
73+ request := cefmocks.NewMockRequest(t)
74+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
75+ request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once()
76+
70 response := cefmocks.NewMockResponse(t)77 response := cefmocks.NewMockResponse(t)
71- response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()78+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
72- response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()79+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
73 response.EXPECT().SetMimeType("application/json").Once()80 response.EXPECT().SetMimeType("application/json").Once()
74 81
75- handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil)82+ handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)
76 require.NotNil(t, handler)83 require.NotNil(t, handler)
77 84
78 var responseLength int6485 var responseLength int64
79 handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)86 handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
80 require.Positive(t, responseLength)87 require.Positive(t, responseLength)
88+ require.Equal(t, "copied from js", copied)
81 }89 }
M internal/infrastructure/cef/webview_handlers.go +18 -1
@@ -699,8 +699,13 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
699699 h.wv.engine.registerWebView(h.wv)
700700 }
701701 host := browser.GetHost()
702+ if host == nil {
703+ log.Warn().Msg("cef: OnAfterCreated returned nil host")
704+ return
705+ }
702706
703707 shouldSyncFocus := false
708+ hasPendingNavigation := false
704709 h.wv.mu.Lock()
705710 h.wv.browser = browser
706711 h.wv.host = host
@@ -710,18 +715,30 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
710715 }
711716
712717 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
713- // UI elements like the text caret until explicitly told the browser is shown.
718+ // painting/caret updates until explicitly told the browser is shown.
714719 host.WasHidden(0)
715720
716721 // If GTK focus entered before the browser existed, reassert browser focus
717722 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
718723 if h.wv.input != nil {
719724 shouldSyncFocus = h.wv.input.hasGTKFocus()
725+ if !shouldSyncFocus && h.wv.input.glArea != nil {
726+ shouldSyncFocus = h.wv.input.glArea.HasFocus()
727+ }
720728 }
729+ hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
721730
722731 h.wv.mu.Unlock()
723732 if shouldSyncFocus {
724733 syncWindowlessBrowserFocus(host)
734+ } else {
735+ // Request an initial OSR frame even before the first real navigation
736+ // commits. This restores the about:blank warm-up paint that the stable
737+ // startup path relied on and prevents the render pipeline from staying idle.
738+ host.Invalidate(purecef.PaintElementTypePetView)
739+ }
740+ if hasPendingNavigation {
741+ h.wv.schedulePendingNavigationReplay(browser, 0)
725742 }
726743
727744 h.wv.scheduleStartBeginFrameLoop()
@@ -699,8 +699,13 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
699 h.wv.engine.registerWebView(h.wv)699 h.wv.engine.registerWebView(h.wv)
700 }700 }
701 host := browser.GetHost()701 host := browser.GetHost()
702+ if host == nil {
703+ log.Warn().Msg("cef: OnAfterCreated returned nil host")
704+ return
705+ }
702 706
703 shouldSyncFocus := false707 shouldSyncFocus := false
708+ hasPendingNavigation := false
704 h.wv.mu.Lock()709 h.wv.mu.Lock()
705 h.wv.browser = browser710 h.wv.browser = browser
706 h.wv.host = host711 h.wv.host = host
@@ -710,18 +715,30 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
710 }715 }
711 716
712 // Mark browser as visible — CEF OSR starts in hidden state and suppresses717 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
713- // UI elements like the text caret until explicitly told the browser is shown.718+ // painting/caret updates until explicitly told the browser is shown.
714 host.WasHidden(0)719 host.WasHidden(0)
715 720
716 // If GTK focus entered before the browser existed, reassert browser focus721 // If GTK focus entered before the browser existed, reassert browser focus
717 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.722 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
718 if h.wv.input != nil {723 if h.wv.input != nil {
719 shouldSyncFocus = h.wv.input.hasGTKFocus()724 shouldSyncFocus = h.wv.input.hasGTKFocus()
725+ if !shouldSyncFocus && h.wv.input.glArea != nil {
726+ shouldSyncFocus = h.wv.input.glArea.HasFocus()
727+ }
720 }728 }
729+ hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
721 730
722 h.wv.mu.Unlock()731 h.wv.mu.Unlock()
723 if shouldSyncFocus {732 if shouldSyncFocus {
724 syncWindowlessBrowserFocus(host)733 syncWindowlessBrowserFocus(host)
734+ } else {
735+ // Request an initial OSR frame even before the first real navigation
736+ // commits. This restores the about:blank warm-up paint that the stable
737+ // startup path relied on and prevents the render pipeline from staying idle.
738+ host.Invalidate(purecef.PaintElementTypePetView)
739+ }
740+ if hasPendingNavigation {
741+ h.wv.schedulePendingNavigationReplay(browser, 0)
725 }742 }
726 743
727 h.wv.scheduleStartBeginFrameLoop()744 h.wv.scheduleStartBeginFrameLoop()

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