fix(cef): restore OSR startup paints
- commit
- 4042f88bacdf874671d37d2096b632f1995812c2
- author
- brice · · 2026-04-18 10:45 UTC
- parent
- 72b6970
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 45 | 45 | type dumberApp struct { | |
| 46 | 46 | engine *Engine | |
| 47 | 47 | bph purecef.BrowserProcessHandler | |
| 48 | - | rph purecef.RenderProcessHandler | |
| 49 | 48 | } | |
| 50 | 49 | ||
| 51 | 50 | // newDumberApp creates an App whose BrowserProcessHandler handles context | |
| @@ -54,15 +53,15 @@ type dumberApp struct { | |||
| 54 | 53 | func newDumberApp(engine *Engine) purecef.App { | |
| 55 | 54 | app := &dumberApp{engine: engine} | |
| 56 | 55 | app.bph = &dumberBPH{engine: engine} | |
| 57 | - | app.rph = newRendererBridgeProcessHandler() | |
| 58 | 56 | return app | |
| 59 | 57 | } | |
| 60 | 58 | ||
| 61 | 59 | // 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. | |
| 64 | 63 | func NewSubprocessApp() purecef.App { | |
| 65 | - | return &subprocessApp{rph: newRendererBridgeProcessHandler()} | |
| 64 | + | return &subprocessApp{} | |
| 66 | 65 | } | |
| 67 | 66 | ||
| 68 | 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 | 92 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 94 | 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 | 98 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | |
| 100 | 99 | configureCommandLine(commandLine) | |
| @@ -104,7 +103,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra | |||
| 104 | 103 | } | |
| 105 | 104 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 106 | 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 | 108 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | |
| 110 | 109 | // 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 *Engine | 46 | engine *Engine | ||
| 47 | bph purecef.BrowserProcessHandler | 47 | bph purecef.BrowserProcessHandler | ||
| 48 | - | rph purecef.RenderProcessHandler | |||
| 49 | } | 48 | } | ||
| 50 | 49 | ||||
| 51 | // newDumberApp creates an App whose BrowserProcessHandler handles context | 50 | // 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 app | 56 | return app | ||
| 59 | } | 57 | } | ||
| 60 | 58 | ||||
| 61 | // NewSubprocessApp returns a lightweight raw App implementation for helper | 59 | // NewSubprocessApp returns a lightweight raw App implementation for helper | ||
| 62 | - | // processes so cef_execute_process can wrap it itself. Do not pre-wrap with | 60 | + | // 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(){ | |||
| 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. | |
| 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 | 416 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 389 | 417 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 390 | 418 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -505,9 +533,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 505 | 533 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 506 | 534 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 507 | 535 | ||
| 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) | |
| 511 | 540 | ||
| 512 | 541 | // Video playback diagnostic — logs video element state changes. | |
| 513 | 542 | // 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 OnLoadEnd | 418 | // 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 by | 536 | + | // 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 inject | 537 | + | // 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | + | "github.com/bnema/puregotk/v4/glib" | |
| 14 | 15 | "github.com/rs/zerolog" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/dumber/assets" | |
| @@ -236,6 +237,25 @@ func wireEngine( | |||
| 236 | 237 | } | |
| 237 | 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 | 259 | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 240 | 260 | ||
| 241 | 261 | 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.handleEditableFocusBridge | 259 | 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( | |||
| 178 | 178 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 179 | 179 | return | |
| 180 | 180 | } | |
| 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" | |
| 185 | 187 | result := purecef.BrowserHostCreateBrowser( | |
| 186 | 188 | pc.windowInfo, | |
| 187 | 189 | pc.client, | |
| @@ -190,7 +192,10 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 190 | 192 | nil, // extraInfo | |
| 191 | 193 | 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 | 199 | if f.engine != nil { | |
| 195 | 200 | f.engine.recordBrowserCreateRequest(w, h, result) | |
| 196 | 201 | } | |
| @@ -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 | return | 179 | 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, // extraInfo | 192 | nil, // extraInfo | ||
| 191 | nil, // requestContext | 193 | 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 { | |||
| 58 | 58 | defaultConfigPayload func() ([]byte, error) | |
| 59 | 59 | 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 | 65 | // onEditableFocus is called when JS reports that an editable element gained | |
| 62 | 66 | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |
| 63 | 67 | onEditableFocus func(browser purecef.Browser) | |
| @@ -164,6 +168,9 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||
| 164 | 168 | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 165 | 169 | return h.handleTranscodeAPI(request) | |
| 166 | 170 | ||
| 171 | + | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 172 | + | return h.handleClipboardSet(request) | |
| 173 | + | ||
| 167 | 174 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 168 | 175 | return h.handleFocusSync(browser) | |
| 169 | 176 | ||
| @@ -240,6 +247,42 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||
| 240 | 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 | 286 | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | |
| 244 | 287 | if browser == nil { | |
| 245 | 288 | 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.RWMutex | 59 | 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 gained | 65 | // 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) { | |||
| 59 | 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 | 63 | oldNewResourceHandler := cefNewResourceHandler | |
| 64 | 64 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 65 | 65 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| @@ -67,15 +67,23 @@ func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) { | |||
| 67 | 67 | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 68 | 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 | 77 | 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() | |
| 73 | 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 | 83 | require.NotNil(t, handler) | |
| 77 | 84 | ||
| 78 | 85 | var responseLength int64 | |
| 79 | 86 | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |
| 80 | 87 | require.Positive(t, responseLength) | |
| 88 | + | require.Equal(t, "copied from js", copied) | |
| 81 | 89 | } | |
| @@ -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 := cefNewResourceHandler | 63 | 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 int64 | 85 | 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) { | |||
| 699 | 699 | h.wv.engine.registerWebView(h.wv) | |
| 700 | 700 | } | |
| 701 | 701 | host := browser.GetHost() | |
| 702 | + | if host == nil { | |
| 703 | + | log.Warn().Msg("cef: OnAfterCreated returned nil host") | |
| 704 | + | return | |
| 705 | + | } | |
| 702 | 706 | ||
| 703 | 707 | shouldSyncFocus := false | |
| 708 | + | hasPendingNavigation := false | |
| 704 | 709 | h.wv.mu.Lock() | |
| 705 | 710 | h.wv.browser = browser | |
| 706 | 711 | h.wv.host = host | |
| @@ -710,18 +715,30 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 710 | 715 | } | |
| 711 | 716 | ||
| 712 | 717 | // 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 | 719 | host.WasHidden(0) | |
| 715 | 720 | ||
| 716 | 721 | // If GTK focus entered before the browser existed, reassert browser focus | |
| 717 | 722 | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | |
| 718 | 723 | if h.wv.input != nil { | |
| 719 | 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 | 731 | h.wv.mu.Unlock() | |
| 723 | 732 | if shouldSyncFocus { | |
| 724 | 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 | 744 | 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 := false | 707 | shouldSyncFocus := false | ||
| 708 | + | hasPendingNavigation := false | |||
| 704 | h.wv.mu.Lock() | 709 | h.wv.mu.Lock() | ||
| 705 | h.wv.browser = browser | 710 | h.wv.browser = browser | ||
| 706 | h.wv.host = host | 711 | 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 suppresses | 717 | // 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 focus | 721 | // 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() | ||