detached 630eef1

refactor: extract shared webutil package and consolidate engine adapters

- Extract common web utilities (crash page, dark mode, JS escaping, MIME types) into webutil package
- Consolidate CEF and WebKit scheme handlers to use shared webutil functions
- Refactor render pipeline with improved dirty rect handling
- Update engine port interfaces and config schema
- Add render pipeline tests
commit
630eef144e57d2d661bef121b917d2bd01160cab
author
brice · · 2026-04-05 06:15 UTC
parent
a5f2194
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
36 files · +796 -735
M cmd/dumber/main.go +1 -1
@@ -110,7 +110,7 @@ func runGUI() int {
110110 }
111111 timer.MarkDuration("parallel_phase", initResult.Duration)
112112
113- if cfg.Engine.CEF.CEFMultiThreadedMessageLoop() && cfg.Engine.ResolveEngineType() == "cef" {
113+ if cfg.Engine.ResolveEngineType() == config.EngineTypeCEF {
114114 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")
115115 ui.EnsureAdwaitaInitialized()
116116 if initResult.AdwaitaDetector != nil {
@@ -110,7 +110,7 @@ func runGUI() int {
110 }110 }
111 timer.MarkDuration("parallel_phase", initResult.Duration)111 timer.MarkDuration("parallel_phase", initResult.Duration)
112 112
113- if cfg.Engine.CEF.CEFMultiThreadedMessageLoop() && cfg.Engine.ResolveEngineType() == "cef" {113+ if cfg.Engine.ResolveEngineType() == config.EngineTypeCEF {
114 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")114 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")
115 ui.EnsureAdwaitaInitialized()115 ui.EnsureAdwaitaInitialized()
116 if initResult.AdwaitaDetector != nil {116 if initResult.AdwaitaDetector != nil {
M internal/application/port/engine.go +15 -10
@@ -182,18 +182,23 @@ type WebUIHandlerRouter interface {
182182 RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler WebUIMessageHandler) error
183183 }
184184
185+// WebUIMessage represents a JS -> Go message envelope.
186+// Used by both WebKit (postMessage) and CEF (fetch to /api/message).
187+type WebUIMessage struct {
188+ Type string `json:"type"`
189+ Payload json.RawMessage `json:"payload"`
190+ WebViewID uint64 `json:"webview_id,omitempty"`
191+ WebViewIDAlt uint64 `json:"webviewId,omitempty"`
192+}
193+
185194 // HandlerDependencies holds use cases needed by WebUI message handlers.
186195 type HandlerDependencies struct {
187- HistoryUC HomepageHistory
188- FavoritesUC HomepageFavorites
189- Clipboard Clipboard
190- AutoCopyConfig AutoCopyConfig
191- OnClipboardCopied func(textLen int)
192- SaveConfig func(context.Context, WebUIConfig) error
193- KeybindingsGetter KeybindingsGetter
194- KeybindingSetter KeybindingSetter
195- KeybindingResetter KeybindingResetter
196- AllKeybindingsResetter AllKeybindingsResetter
196+ HistoryUC HomepageHistory
197+ FavoritesUC HomepageFavorites
198+ Clipboard Clipboard
199+ AutoCopyConfig AutoCopyConfig
200+ OnClipboardCopied func(textLen int)
201+ HandlerDeps
197202 }
198203
199204 // SchemeHandler defines the port interface for registering custom URI schemes.
@@ -182,18 +182,23 @@ type WebUIHandlerRouter interface {
182 RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler WebUIMessageHandler) error182 RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler WebUIMessageHandler) error
183 }183 }
184 184
185+// WebUIMessage represents a JS -> Go message envelope.
186+// Used by both WebKit (postMessage) and CEF (fetch to /api/message).
187+type WebUIMessage struct {
188+ Type string `json:"type"`
189+ Payload json.RawMessage `json:"payload"`
190+ WebViewID uint64 `json:"webview_id,omitempty"`
191+ WebViewIDAlt uint64 `json:"webviewId,omitempty"`
192+}
193+
185 // HandlerDependencies holds use cases needed by WebUI message handlers.194 // HandlerDependencies holds use cases needed by WebUI message handlers.
186 type HandlerDependencies struct {195 type HandlerDependencies struct {
187- HistoryUC HomepageHistory196+ HistoryUC HomepageHistory
188- FavoritesUC HomepageFavorites197+ FavoritesUC HomepageFavorites
189- Clipboard Clipboard198+ Clipboard Clipboard
190- AutoCopyConfig AutoCopyConfig199+ AutoCopyConfig AutoCopyConfig
191- OnClipboardCopied func(textLen int)200+ OnClipboardCopied func(textLen int)
192- SaveConfig func(context.Context, WebUIConfig) error201+ HandlerDeps
193- KeybindingsGetter KeybindingsGetter
194- KeybindingSetter KeybindingSetter
195- KeybindingResetter KeybindingResetter
196- AllKeybindingsResetter AllKeybindingsResetter
197 }202 }
198 203
199 // SchemeHandler defines the port interface for registering custom URI schemes.204 // SchemeHandler defines the port interface for registering custom URI schemes.
M internal/bootstrap/engine.go +2 -2
@@ -28,7 +28,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
2828 cfg := input.Config
2929 engineType := cfg.Engine.ResolveEngineType()
3030 switch engineType {
31- case "webkit":
31+ case config.EngineTypeWebKit:
3232 opts := port.EngineOptions{
3333 DataDir: input.DataDir,
3434 CacheDir: input.CacheDir,
@@ -40,7 +40,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4040 input.Ctx, cfg, opts, wkCfg,
4141 input.ThemeManager, input.ColorResolver, input.Logger,
4242 )
43- case "cef":
43+ case config.EngineTypeCEF:
4444 cefCfg := cfg.Engine.CEF
4545 return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding)
4646 default:
@@ -28,7 +28,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
28 cfg := input.Config28 cfg := input.Config
29 engineType := cfg.Engine.ResolveEngineType()29 engineType := cfg.Engine.ResolveEngineType()
30 switch engineType {30 switch engineType {
31- case "webkit":31+ case config.EngineTypeWebKit:
32 opts := port.EngineOptions{32 opts := port.EngineOptions{
33 DataDir: input.DataDir,33 DataDir: input.DataDir,
34 CacheDir: input.CacheDir,34 CacheDir: input.CacheDir,
@@ -40,7 +40,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
40 input.Ctx, cfg, opts, wkCfg,40 input.Ctx, cfg, opts, wkCfg,
41 input.ThemeManager, input.ColorResolver, input.Logger,41 input.ThemeManager, input.ColorResolver, input.Logger,
42 )42 )
43- case "cef":43+ case config.EngineTypeCEF:
44 cefCfg := cfg.Engine.CEF44 cefCfg := cfg.Engine.CEF
45 return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding)45 return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding)
46 default:46 default:
M internal/bootstrap/engine_test.go +1 -1
@@ -22,5 +22,5 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
2222 cfg.Engine.CEF.CEFDir = t.TempDir()
2323 _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
2424 require.Error(t, err)
25- require.Contains(t, err.Error(), "cef: open library")
25+ require.Contains(t, err.Error(), "cef.InitWithApp")
2626 }
@@ -22,5 +22,5 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
22 cfg.Engine.CEF.CEFDir = t.TempDir()22 cfg.Engine.CEF.CEFDir = t.TempDir()
23 _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})23 _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
24 require.Error(t, err)24 require.Error(t, err)
25- require.Contains(t, err.Error(), "cef: open library")25+ require.Contains(t, err.Error(), "cef.InitWithApp")
26 }26 }
M internal/infrastructure/cef/app.go +11 -14
@@ -38,21 +38,20 @@ func configureCommandLine(commandLine purecef.CommandLine) {
3838 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
3939 }
4040
41-// dumberApp implements purecef.App to provide a BrowserProcessHandler with
42-// demand-driven message pump scheduling (OnScheduleMessagePumpWork).
41+// dumberApp implements purecef.App to provide custom scheme registration,
42+// command-line configuration, and browser-process lifecycle callbacks.
4343 type dumberApp struct {
4444 engine *Engine
4545 bph purecef.BrowserProcessHandler
4646 }
4747
48-// newDumberApp creates an App whose BrowserProcessHandler drives the engine's
49-// adaptive message pump.
48+// newDumberApp creates an App whose BrowserProcessHandler handles context
49+// initialization, child process launch tracking, and scheme registration.
50+// Returns the raw Go implementation — InitWithApp will wrap it exactly once.
5051 func newDumberApp(engine *Engine) purecef.App {
5152 app := &dumberApp{engine: engine}
52- // Keep the raw Go implementation here. NewApp will wrap it exactly once
53- // when GetBrowserProcessHandler is queried by CEF.
5453 app.bph = &dumberBPH{engine: engine}
55- return purecef.NewApp(app)
54+ return app
5655 }
5756
5857 // NewSubprocessApp returns a lightweight App for helper processes so CEF sees
@@ -102,8 +101,8 @@ func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler
102101 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
103102 func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
104103
105-// dumberBPH implements purecef.BrowserProcessHandler. Only
106-// OnScheduleMessagePumpWork carries real logic; the rest are no-ops.
104+// dumberBPH implements purecef.BrowserProcessHandler for context initialization,
105+// child process launch tracking, and diagnostic logging.
107106 type dumberBPH struct {
108107 engine *Engine
109108 }
@@ -130,8 +129,6 @@ func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string)
130129 func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
131130 func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
132131
133-// OnScheduleMessagePumpWork is called by CEF when work needs to be done.
134-// delay_ms <= 0 means "as soon as possible"; > 0 means "after this delay".
135-func (h *dumberBPH) OnScheduleMessagePumpWork(delayMs int64) {
136- h.engine.scheduleMessagePumpWork(delayMs)
137-}
132+// OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives
133+// its own pump. Required by the BrowserProcessHandler interface.
134+func (h *dumberBPH) OnScheduleMessagePumpWork(_ int64) {}
@@ -38,21 +38,20 @@ func configureCommandLine(commandLine purecef.CommandLine) {
38 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")38 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
39 }39 }
40 40
41-// dumberApp implements purecef.App to provide a BrowserProcessHandler with41+// dumberApp implements purecef.App to provide custom scheme registration,
42-// demand-driven message pump scheduling (OnScheduleMessagePumpWork).42+// command-line configuration, and browser-process lifecycle callbacks.
43 type dumberApp struct {43 type dumberApp struct {
44 engine *Engine44 engine *Engine
45 bph purecef.BrowserProcessHandler45 bph purecef.BrowserProcessHandler
46 }46 }
47 47
48-// newDumberApp creates an App whose BrowserProcessHandler drives the engine's48+// newDumberApp creates an App whose BrowserProcessHandler handles context
49-// adaptive message pump.49+// initialization, child process launch tracking, and scheme registration.
50+// Returns the raw Go implementation — InitWithApp will wrap it exactly once.
50 func newDumberApp(engine *Engine) purecef.App {51 func newDumberApp(engine *Engine) purecef.App {
51 app := &dumberApp{engine: engine}52 app := &dumberApp{engine: engine}
52- // Keep the raw Go implementation here. NewApp will wrap it exactly once
53- // when GetBrowserProcessHandler is queried by CEF.
54 app.bph = &dumberBPH{engine: engine}53 app.bph = &dumberBPH{engine: engine}
55- return purecef.NewApp(app)54+ return app
56 }55 }
57 56
58 // NewSubprocessApp returns a lightweight App for helper processes so CEF sees57 // NewSubprocessApp returns a lightweight App for helper processes so CEF sees
@@ -102,8 +101,8 @@ func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler
102 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }101 func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
103 func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }102 func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
104 103
105-// dumberBPH implements purecef.BrowserProcessHandler. Only104+// dumberBPH implements purecef.BrowserProcessHandler for context initialization,
106-// OnScheduleMessagePumpWork carries real logic; the rest are no-ops.105+// child process launch tracking, and diagnostic logging.
107 type dumberBPH struct {106 type dumberBPH struct {
108 engine *Engine107 engine *Engine
109 }108 }
@@ -130,8 +129,6 @@ func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string)
130 func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }129 func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
131 func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }130 func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
132 131
133-// OnScheduleMessagePumpWork is called by CEF when work needs to be done.132+// OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives
134-// delay_ms <= 0 means "as soon as possible"; > 0 means "after this delay".133+// its own pump. Required by the BrowserProcessHandler interface.
135-func (h *dumberBPH) OnScheduleMessagePumpWork(delayMs int64) {134+func (h *dumberBPH) OnScheduleMessagePumpWork(_ int64) {}
136- h.engine.scheduleMessagePumpWork(delayMs)
137-}
M internal/infrastructure/cef/engine.go +15 -151
@@ -2,14 +2,11 @@ package cef
22
33 import (
44 "context"
5- "encoding/json"
65 "sync"
76 "sync/atomic"
87 "time"
9- "unicode/utf8"
108
119 purecef "github.com/bnema/purego-cef/cef"
12- "github.com/bnema/puregotk/v4/glib"
1310
1411 "github.com/bnema/dumber/internal/application/port"
1512 "github.com/bnema/dumber/internal/infrastructure/handlers"
@@ -33,23 +30,13 @@ type Engine struct {
3330
3431 // activeWebViews tracks all live webviews for CSS broadcast.
3532 activeWebViews sync.Map // map[port.WebViewID]*WebView
33+ activeCount atomic.Int32
3634
3735 // shutdownNotify is signalled when the active webview count reaches 0
3836 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
3937 shutdownNotify chan struct{}
4038
41- multiThreadedMessageLoop bool
42- manualPumpInterval int64
43-
44- // Pump lifecycle (manual pump mode only).
45- pumpEnabled atomic.Bool
46- pumpClosing atomic.Bool
47- pumpTimerSource atomic.Uint64
48-
4939 // Diagnostic counters.
50- pumpWorkCount atomic.Uint64
51- scheduleCount atomic.Uint64 // OnScheduleMessagePumpWork call count (currently always 0)
52-
5340 contextInitializedCount atomic.Uint64
5441 childLaunchRenderer atomic.Uint64
5542 childLaunchGPU atomic.Uint64
@@ -62,6 +49,7 @@ type Engine struct {
6249 browserCreateLastWidth atomic.Int32
6350 browserCreateLastHeight atomic.Int32
6451 lastStallLoggedCreateSeq atomic.Uint64
52+ browserCreateComplete atomic.Bool
6553
6654 transcoderState transcoderStartupState
6755 transcoderStateLogged atomic.Bool
@@ -79,17 +67,14 @@ type transcoderStartupState struct {
7967 Decoders []string
8068 }
8169
82-// Factory returns the WebViewFactory for creating new WebView instances.
8370 func (e *Engine) Factory() port.WebViewFactory {
8471 return &webViewFactoryAdapter{factory: e.factory}
8572 }
8673
87-// Pool returns the WebViewPool for acquiring pre-warmed WebView instances.
8874 func (e *Engine) Pool() port.WebViewPool {
8975 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
9076 }
9177
92-// ContentInjector returns the CEF content injector for script/style injection.
9378 func (e *Engine) ContentInjector() port.ContentInjector {
9479 if e.contentInj != nil {
9580 return e.contentInj
@@ -124,13 +109,14 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
124109 // registerWebView adds a webview to the active tracking map.
125110 func (e *Engine) registerWebView(wv *WebView) {
126111 e.activeWebViews.Store(wv.id, wv)
112+ e.activeCount.Add(1)
127113 }
128114
129115 // unregisterWebView removes a webview from the active tracking map.
130116 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
131117 func (e *Engine) unregisterWebView(wv *WebView) {
132118 e.activeWebViews.Delete(wv.id)
133- if e.shutdownNotify != nil && e.activeWebViewCount() == 0 {
119+ if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
134120 select {
135121 case e.shutdownNotify <- struct{}{}:
136122 default:
@@ -154,7 +140,6 @@ func (e *Engine) Close() error {
154140 Int("active_webviews", activeBefore).
155141 Msg("cef: engine close started")
156142
157- e.stopMessagePump()
158143 e.closeActiveWebViews()
159144 e.pool.Close()
160145
@@ -172,12 +157,7 @@ func (e *Engine) Close() error {
172157 const cefShutdownWaitTimeout = 2 * time.Second
173158
174159 func (e *Engine) activeWebViewCount() int {
175- count := 0
176- e.activeWebViews.Range(func(_, _ any) bool {
177- count++
178- return true
179- })
180- return count
160+ return int(e.activeCount.Load())
181161 }
182162
183163 func (e *Engine) closeActiveWebViews() {
@@ -239,54 +219,11 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
239219 return handlers.RegisterAll(ctx, e.messageRouter, deps)
240220 }
241221
242-// RegisterAccentHandlers registers accent/dead-key input handlers with the message router.
243222 func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
244223 if e.messageRouter == nil || handler == nil {
245224 return nil
246225 }
247- log := logging.FromContext(ctx)
248-
249- if err := e.messageRouter.registerInternalHandler("accent_key_press", MessageHandlerFunc(
250- func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
251- var p struct {
252- Char string `json:"char"`
253- Shift bool `json:"shift"`
254- }
255- if err := json.Unmarshal(payload, &p); err != nil {
256- return nil, err
257- }
258- if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
259- handler.OnKeyPressed(ctx, r, p.Shift)
260- } else {
261- log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char")
262- }
263- return nil, nil
264- },
265- )); err != nil {
266- return err
267- }
268-
269- if err := e.messageRouter.registerInternalHandler("accent_key_release", MessageHandlerFunc(
270- func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
271- var p struct {
272- Char string `json:"char"`
273- }
274- if err := json.Unmarshal(payload, &p); err != nil {
275- return nil, err
276- }
277- if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
278- handler.OnKeyReleased(ctx, r)
279- } else {
280- log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char")
281- }
282- return nil, nil
283- },
284- )); err != nil {
285- return err
286- }
287-
288- log.Info().Msg("cef: accent handlers registered")
289- return nil
226+ return handlers.RegisterAccentHandlers(ctx, e.messageRouter, handler)
290227 }
291228
292229 // ConfigureDownloads sets up download handling (Phase 1 stub).
@@ -295,14 +232,11 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
295232 }
296233
297234 // OnToolkitReady is called after the UI toolkit has initialized.
298-// Starts the persistent pump timer on the GTK main thread.
235+// With multi-threaded message loop enabled, CEF drives its own pump
236+// and no manual pump is needed.
299237 func (e *Engine) OnToolkitReady(_ context.Context) error {
300238 log := logging.FromContext(e.ctx)
301- log.Debug().
302- Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
303- Int64("manual_pump_interval_ms", e.manualPumpInterval).
304- Msg("cef: OnToolkitReady called, starting message pump")
305- e.startMessagePump()
239+ log.Debug().Msg("cef: OnToolkitReady called")
306240 return nil
307241 }
308242
@@ -383,6 +317,9 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
383317
384318 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
385319 count := e.browserAfterCreated.Add(1)
320+ if count >= e.browserCreateRequests.Load() {
321+ e.browserCreateComplete.Store(true)
322+ }
386323 browserID := int32(0)
387324 if browser != nil {
388325 browserID = browser.GetIdentifier()
@@ -395,6 +332,9 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
395332 }
396333
397334 func (e *Engine) maybeLogBrowserCreateStall() {
335+ if e.browserCreateComplete.Load() {
336+ return
337+ }
398338 createCount := e.browserCreateRequests.Load()
399339 if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() {
400340 return
@@ -418,8 +358,6 @@ func (e *Engine) maybeLogBrowserCreateStall() {
418358 Int32("last_create_result", e.browserCreateLastResult.Load()).
419359 Int32("last_create_width", e.browserCreateLastWidth.Load()).
420360 Int32("last_create_height", e.browserCreateLastHeight.Load()).
421- Uint64("pump_work", e.pumpWorkCount.Load()).
422- Uint64("schedule_calls", e.scheduleCount.Load()).
423361 Uint64("context_initialized", e.contextInitializedCount.Load()).
424362 Uint64("renderer_launches", e.childLaunchRenderer.Load()).
425363 Uint64("gpu_launches", e.childLaunchGPU.Load()).
@@ -463,77 +401,3 @@ func (e *Engine) logTranscoderStartupState() {
463401
464402 event.Msg("cef: transcoder startup state")
465403 }
466-
467-// ---------------------------------------------------------------------------
468-// CEF message pump
469-// ---------------------------------------------------------------------------
470-
471-// scheduleMessagePumpWork is called by OnScheduleMessagePumpWork from any
472-// thread. External message pump is disabled (purego-cef bug), so this is a
473-// no-op with a diagnostic counter.
474-func (e *Engine) scheduleMessagePumpWork(_ int64) {
475- e.scheduleCount.Add(1)
476-}
477-
478-func (e *Engine) startMessagePump() {
479- if e.multiThreadedMessageLoop {
480- logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping manual pump")
481- return
482- }
483- e.startManualMessagePump()
484-}
485-
486-func (e *Engine) startManualMessagePump() {
487- if e.pumpEnabled.Load() {
488- return
489- }
490-
491- intervalMs := e.manualPumpInterval
492- if intervalMs <= 0 {
493- intervalMs = 10
494- }
495-
496- cb := glib.SourceFunc(func(_ uintptr) bool {
497- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
498- return glib.SOURCE_REMOVE
499- }
500-
501- count := e.pumpWorkCount.Add(1)
502- e.maybeLogBrowserCreateStall()
503- if count <= 20 || count%100 == 0 {
504- logging.FromContext(e.ctx).Debug().
505- Uint64("work", count).
506- Int64("interval_ms", intervalMs).
507- Msg("cef: manual pump doing work")
508- }
509-
510- purecef.DoMessageLoopWork()
511- return glib.SOURCE_CONTINUE
512- })
513-
514- sourceID := glib.TimeoutAdd(uint(intervalMs), &cb, 0)
515- if sourceID == 0 {
516- logging.FromContext(e.ctx).Error().
517- Int64("interval_ms", intervalMs).
518- Msg("cef: failed to start manual pump timer")
519- return
520- }
521-
522- e.pumpClosing.Store(false)
523- e.pumpEnabled.Store(true)
524- e.pumpTimerSource.Store(uint64(sourceID))
525- logging.FromContext(e.ctx).Info().
526- Int64("interval_ms", intervalMs).
527- Msg("cef: started manual CEF pump")
528-}
529-
530-func (e *Engine) stopMessagePump() {
531- if e.multiThreadedMessageLoop {
532- return
533- }
534- e.pumpClosing.Store(true)
535- e.pumpEnabled.Store(false)
536- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
537- glib.SourceRemove(sourceID)
538- }
539-}
@@ -2,14 +2,11 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/json"
6 "sync"5 "sync"
7 "sync/atomic"6 "sync/atomic"
8 "time"7 "time"
9- "unicode/utf8"
10 8
11 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
12- "github.com/bnema/puregotk/v4/glib"
13 10
14 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/infrastructure/handlers"12 "github.com/bnema/dumber/internal/infrastructure/handlers"
@@ -33,23 +30,13 @@ type Engine struct {
33 30
34 // activeWebViews tracks all live webviews for CSS broadcast.31 // activeWebViews tracks all live webviews for CSS broadcast.
35 activeWebViews sync.Map // map[port.WebViewID]*WebView32 activeWebViews sync.Map // map[port.WebViewID]*WebView
33+ activeCount atomic.Int32
36 34
37 // shutdownNotify is signalled when the active webview count reaches 035 // shutdownNotify is signalled when the active webview count reaches 0
38 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.36 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
39 shutdownNotify chan struct{}37 shutdownNotify chan struct{}
40 38
41- multiThreadedMessageLoop bool
42- manualPumpInterval int64
43-
44- // Pump lifecycle (manual pump mode only).
45- pumpEnabled atomic.Bool
46- pumpClosing atomic.Bool
47- pumpTimerSource atomic.Uint64
48-
49 // Diagnostic counters.39 // Diagnostic counters.
50- pumpWorkCount atomic.Uint64
51- scheduleCount atomic.Uint64 // OnScheduleMessagePumpWork call count (currently always 0)
52-
53 contextInitializedCount atomic.Uint6440 contextInitializedCount atomic.Uint64
54 childLaunchRenderer atomic.Uint6441 childLaunchRenderer atomic.Uint64
55 childLaunchGPU atomic.Uint6442 childLaunchGPU atomic.Uint64
@@ -62,6 +49,7 @@ type Engine struct {
62 browserCreateLastWidth atomic.Int3249 browserCreateLastWidth atomic.Int32
63 browserCreateLastHeight atomic.Int3250 browserCreateLastHeight atomic.Int32
64 lastStallLoggedCreateSeq atomic.Uint6451 lastStallLoggedCreateSeq atomic.Uint64
52+ browserCreateComplete atomic.Bool
65 53
66 transcoderState transcoderStartupState54 transcoderState transcoderStartupState
67 transcoderStateLogged atomic.Bool55 transcoderStateLogged atomic.Bool
@@ -79,17 +67,14 @@ type transcoderStartupState struct {
79 Decoders []string67 Decoders []string
80 }68 }
81 69
82-// Factory returns the WebViewFactory for creating new WebView instances.
83 func (e *Engine) Factory() port.WebViewFactory {70 func (e *Engine) Factory() port.WebViewFactory {
84 return &webViewFactoryAdapter{factory: e.factory}71 return &webViewFactoryAdapter{factory: e.factory}
85 }72 }
86 73
87-// Pool returns the WebViewPool for acquiring pre-warmed WebView instances.
88 func (e *Engine) Pool() port.WebViewPool {74 func (e *Engine) Pool() port.WebViewPool {
89 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}75 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
90 }76 }
91 77
92-// ContentInjector returns the CEF content injector for script/style injection.
93 func (e *Engine) ContentInjector() port.ContentInjector {78 func (e *Engine) ContentInjector() port.ContentInjector {
94 if e.contentInj != nil {79 if e.contentInj != nil {
95 return e.contentInj80 return e.contentInj
@@ -124,13 +109,14 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
124 // registerWebView adds a webview to the active tracking map.109 // registerWebView adds a webview to the active tracking map.
125 func (e *Engine) registerWebView(wv *WebView) {110 func (e *Engine) registerWebView(wv *WebView) {
126 e.activeWebViews.Store(wv.id, wv)111 e.activeWebViews.Store(wv.id, wv)
112+ e.activeCount.Add(1)
127 }113 }
128 114
129 // unregisterWebView removes a webview from the active tracking map.115 // unregisterWebView removes a webview from the active tracking map.
130 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.116 // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
131 func (e *Engine) unregisterWebView(wv *WebView) {117 func (e *Engine) unregisterWebView(wv *WebView) {
132 e.activeWebViews.Delete(wv.id)118 e.activeWebViews.Delete(wv.id)
133- if e.shutdownNotify != nil && e.activeWebViewCount() == 0 {119+ if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
134 select {120 select {
135 case e.shutdownNotify <- struct{}{}:121 case e.shutdownNotify <- struct{}{}:
136 default:122 default:
@@ -154,7 +140,6 @@ func (e *Engine) Close() error {
154 Int("active_webviews", activeBefore).140 Int("active_webviews", activeBefore).
155 Msg("cef: engine close started")141 Msg("cef: engine close started")
156 142
157- e.stopMessagePump()
158 e.closeActiveWebViews()143 e.closeActiveWebViews()
159 e.pool.Close()144 e.pool.Close()
160 145
@@ -172,12 +157,7 @@ func (e *Engine) Close() error {
172 const cefShutdownWaitTimeout = 2 * time.Second157 const cefShutdownWaitTimeout = 2 * time.Second
173 158
174 func (e *Engine) activeWebViewCount() int {159 func (e *Engine) activeWebViewCount() int {
175- count := 0160+ return int(e.activeCount.Load())
176- e.activeWebViews.Range(func(_, _ any) bool {
177- count++
178- return true
179- })
180- return count
181 }161 }
182 162
183 func (e *Engine) closeActiveWebViews() {163 func (e *Engine) closeActiveWebViews() {
@@ -239,54 +219,11 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
239 return handlers.RegisterAll(ctx, e.messageRouter, deps)219 return handlers.RegisterAll(ctx, e.messageRouter, deps)
240 }220 }
241 221
242-// RegisterAccentHandlers registers accent/dead-key input handlers with the message router.
243 func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {222 func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
244 if e.messageRouter == nil || handler == nil {223 if e.messageRouter == nil || handler == nil {
245 return nil224 return nil
246 }225 }
247- log := logging.FromContext(ctx)226+ return handlers.RegisterAccentHandlers(ctx, e.messageRouter, handler)
248-
249- if err := e.messageRouter.registerInternalHandler("accent_key_press", MessageHandlerFunc(
250- func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
251- var p struct {
252- Char string `json:"char"`
253- Shift bool `json:"shift"`
254- }
255- if err := json.Unmarshal(payload, &p); err != nil {
256- return nil, err
257- }
258- if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
259- handler.OnKeyPressed(ctx, r, p.Shift)
260- } else {
261- log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char")
262- }
263- return nil, nil
264- },
265- )); err != nil {
266- return err
267- }
268-
269- if err := e.messageRouter.registerInternalHandler("accent_key_release", MessageHandlerFunc(
270- func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
271- var p struct {
272- Char string `json:"char"`
273- }
274- if err := json.Unmarshal(payload, &p); err != nil {
275- return nil, err
276- }
277- if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
278- handler.OnKeyReleased(ctx, r)
279- } else {
280- log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char")
281- }
282- return nil, nil
283- },
284- )); err != nil {
285- return err
286- }
287-
288- log.Info().Msg("cef: accent handlers registered")
289- return nil
290 }227 }
291 228
292 // ConfigureDownloads sets up download handling (Phase 1 stub).229 // ConfigureDownloads sets up download handling (Phase 1 stub).
@@ -295,14 +232,11 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
295 }232 }
296 233
297 // OnToolkitReady is called after the UI toolkit has initialized.234 // OnToolkitReady is called after the UI toolkit has initialized.
298-// Starts the persistent pump timer on the GTK main thread.235+// With multi-threaded message loop enabled, CEF drives its own pump
236+// and no manual pump is needed.
299 func (e *Engine) OnToolkitReady(_ context.Context) error {237 func (e *Engine) OnToolkitReady(_ context.Context) error {
300 log := logging.FromContext(e.ctx)238 log := logging.FromContext(e.ctx)
301- log.Debug().239+ log.Debug().Msg("cef: OnToolkitReady called")
302- Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
303- Int64("manual_pump_interval_ms", e.manualPumpInterval).
304- Msg("cef: OnToolkitReady called, starting message pump")
305- e.startMessagePump()
306 return nil240 return nil
307 }241 }
308 242
@@ -383,6 +317,9 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
383 317
384 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {318 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
385 count := e.browserAfterCreated.Add(1)319 count := e.browserAfterCreated.Add(1)
320+ if count >= e.browserCreateRequests.Load() {
321+ e.browserCreateComplete.Store(true)
322+ }
386 browserID := int32(0)323 browserID := int32(0)
387 if browser != nil {324 if browser != nil {
388 browserID = browser.GetIdentifier()325 browserID = browser.GetIdentifier()
@@ -395,6 +332,9 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
395 }332 }
396 333
397 func (e *Engine) maybeLogBrowserCreateStall() {334 func (e *Engine) maybeLogBrowserCreateStall() {
335+ if e.browserCreateComplete.Load() {
336+ return
337+ }
398 createCount := e.browserCreateRequests.Load()338 createCount := e.browserCreateRequests.Load()
399 if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() {339 if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() {
400 return340 return
@@ -418,8 +358,6 @@ func (e *Engine) maybeLogBrowserCreateStall() {
418 Int32("last_create_result", e.browserCreateLastResult.Load()).358 Int32("last_create_result", e.browserCreateLastResult.Load()).
419 Int32("last_create_width", e.browserCreateLastWidth.Load()).359 Int32("last_create_width", e.browserCreateLastWidth.Load()).
420 Int32("last_create_height", e.browserCreateLastHeight.Load()).360 Int32("last_create_height", e.browserCreateLastHeight.Load()).
421- Uint64("pump_work", e.pumpWorkCount.Load()).
422- Uint64("schedule_calls", e.scheduleCount.Load()).
423 Uint64("context_initialized", e.contextInitializedCount.Load()).361 Uint64("context_initialized", e.contextInitializedCount.Load()).
424 Uint64("renderer_launches", e.childLaunchRenderer.Load()).362 Uint64("renderer_launches", e.childLaunchRenderer.Load()).
425 Uint64("gpu_launches", e.childLaunchGPU.Load()).363 Uint64("gpu_launches", e.childLaunchGPU.Load()).
@@ -463,77 +401,3 @@ func (e *Engine) logTranscoderStartupState() {
463 401
464 event.Msg("cef: transcoder startup state")402 event.Msg("cef: transcoder startup state")
465 }403 }
466-
467-// ---------------------------------------------------------------------------
468-// CEF message pump
469-// ---------------------------------------------------------------------------
470-
471-// scheduleMessagePumpWork is called by OnScheduleMessagePumpWork from any
472-// thread. External message pump is disabled (purego-cef bug), so this is a
473-// no-op with a diagnostic counter.
474-func (e *Engine) scheduleMessagePumpWork(_ int64) {
475- e.scheduleCount.Add(1)
476-}
477-
478-func (e *Engine) startMessagePump() {
479- if e.multiThreadedMessageLoop {
480- logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping manual pump")
481- return
482- }
483- e.startManualMessagePump()
484-}
485-
486-func (e *Engine) startManualMessagePump() {
487- if e.pumpEnabled.Load() {
488- return
489- }
490-
491- intervalMs := e.manualPumpInterval
492- if intervalMs <= 0 {
493- intervalMs = 10
494- }
495-
496- cb := glib.SourceFunc(func(_ uintptr) bool {
497- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
498- return glib.SOURCE_REMOVE
499- }
500-
501- count := e.pumpWorkCount.Add(1)
502- e.maybeLogBrowserCreateStall()
503- if count <= 20 || count%100 == 0 {
504- logging.FromContext(e.ctx).Debug().
505- Uint64("work", count).
506- Int64("interval_ms", intervalMs).
507- Msg("cef: manual pump doing work")
508- }
509-
510- purecef.DoMessageLoopWork()
511- return glib.SOURCE_CONTINUE
512- })
513-
514- sourceID := glib.TimeoutAdd(uint(intervalMs), &cb, 0)
515- if sourceID == 0 {
516- logging.FromContext(e.ctx).Error().
517- Int64("interval_ms", intervalMs).
518- Msg("cef: failed to start manual pump timer")
519- return
520- }
521-
522- e.pumpClosing.Store(false)
523- e.pumpEnabled.Store(true)
524- e.pumpTimerSource.Store(uint64(sourceID))
525- logging.FromContext(e.ctx).Info().
526- Int64("interval_ms", intervalMs).
527- Msg("cef: started manual CEF pump")
528-}
529-
530-func (e *Engine) stopMessagePump() {
531- if e.multiThreadedMessageLoop {
532- return
533- }
534- e.pumpClosing.Store(true)
535- e.pumpEnabled.Store(false)
536- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
537- glib.SourceRemove(sourceID)
538- }
539-}
M internal/infrastructure/cef/engine_init.go +13 -41
@@ -28,8 +28,6 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
2828 logger := logging.FromContext(ctx)
2929 cleanStaleSingletonLocks(logger)
3030
31- multiThreaded := cfg.CEFMultiThreadedMessageLoop()
32- manualPumpMs := cfg.CEFManualPumpIntervalMs()
3331 windowlessFrameRate := cfg.CEFWindowlessFrameRate()
3432
3533 settings := prepareCEFSettings(cfg, logger)
@@ -40,21 +38,18 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
4038 os.Args = appendIfMissing(os.Args, "--no-zygote")
4139
4240 eng := &Engine{
43- ctx: ctx,
44- multiThreadedMessageLoop: multiThreaded,
45- manualPumpInterval: manualPumpMs,
41+ ctx: ctx,
4642 }
4743
4844 logger.Info().
49- Bool("multi_threaded_message_loop", multiThreaded).
50- Int64("manual_pump_interval_ms", manualPumpMs).
5145 Int32("windowless_frame_rate", windowlessFrameRate).
46+ Bool("external_begin_frame", externalBeginFrameEnabled()).
5247 Bool("trace_handlers", cfg.TraceHandlers).
5348 Bool("enable_audio_handler", cfg.EnableAudioHandler).
5449 Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
5550 Msg("cef: configured engine")
5651
57- if err := initializeCEF(eng, settings, multiThreaded, logger); err != nil {
52+ if err := initializeCEF(eng, settings, logger); err != nil {
5853 os.Args = savedArgs
5954 return nil, err
6055 }
@@ -66,7 +61,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
6661 // prepareCEFSettings builds purecef.Settings from the engine config.
6762 func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings {
6863 settings := purecef.DefaultSettings()
69- settings.MultiThreadedMessageLoop = cfg.CEFMultiThreadedMessageLoop()
64+ settings.MultiThreadedMessageLoop = true
7065 settings.ExternalMessagePump = false
7166 if cfg.CEFDir != "" {
7267 settings.CEFDir = cfg.CEFDir
@@ -97,22 +92,15 @@ func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) pure
9792 return settings
9893 }
9994
100-// initializeCEF calls cef_initialize with or without an App depending on pump mode.
101-func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, logger *zerolog.Logger) error {
102- if multiThreaded {
103- logger.Debug().Msg("cef: calling Init")
104- if err := purecef.Init(settings); err != nil {
105- return fmt.Errorf("cef.Init: %w", err)
106- }
107- logger.Debug().Msg("cef: Init returned OK")
108- } else {
109- app := newDumberApp(eng)
110- logger.Debug().Msg("cef: calling InitWithApp")
111- if err := purecef.InitWithApp(settings, app); err != nil {
112- return fmt.Errorf("cef.InitWithApp: %w", err)
113- }
114- logger.Debug().Msg("cef: InitWithApp returned OK")
95+// initializeCEF calls cef_initialize with the App to register custom schemes
96+// and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
97+func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
98+ app := newDumberApp(eng)
99+ logger.Debug().Msg("cef: calling InitWithApp")
100+ if err := purecef.InitWithApp(settings, app); err != nil {
101+ return fmt.Errorf("cef.InitWithApp: %w", err)
115102 }
103+ logger.Debug().Msg("cef: InitWithApp returned OK")
116104 return nil
117105 }
118106
@@ -129,17 +117,6 @@ func wireEngine(
129117
130118 scale := detectHiDPIScale(logger)
131119
132- // Auto-detect frame rate from monitor when config uses the default (0 = auto).
133- // CEFWindowlessFrameRate() returns 0 when the user hasn't explicitly set a value,
134- // allowing us to match the monitor's refresh rate for smoother scrolling.
135- if windowlessFrameRate == 0 {
136- if hz := detectMonitorRefreshRate(logger); hz > 0 {
137- windowlessFrameRate = hz
138- } else {
139- windowlessFrameRate = 60 // safe fallback
140- }
141- }
142-
143120 // Initialize the GPU transcoder when enabled in the loaded app config.
144121 var mediaTranscoder port.MediaTranscoder
145122 transcoderState := buildTranscoderStartupState(transcodingCfg)
@@ -428,10 +405,5 @@ func prepareCEFInitTraceFile(cfg config.CEFEngineConfig) (string, error) {
428405 }
429406
430407 func puregoCEFInitTraceEnabled() bool {
431- switch strings.ToLower(strings.TrimSpace(os.Getenv(puregoCEFInitTraceEnvVar))) {
432- case "1", "true", "yes", "on":
433- return true
434- default:
435- return false
436- }
408+ return envBoolEnabled(puregoCEFInitTraceEnvVar)
437409 }
@@ -28,8 +28,6 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
28 logger := logging.FromContext(ctx)28 logger := logging.FromContext(ctx)
29 cleanStaleSingletonLocks(logger)29 cleanStaleSingletonLocks(logger)
30 30
31- multiThreaded := cfg.CEFMultiThreadedMessageLoop()
32- manualPumpMs := cfg.CEFManualPumpIntervalMs()
33 windowlessFrameRate := cfg.CEFWindowlessFrameRate()31 windowlessFrameRate := cfg.CEFWindowlessFrameRate()
34 32
35 settings := prepareCEFSettings(cfg, logger)33 settings := prepareCEFSettings(cfg, logger)
@@ -40,21 +38,18 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
40 os.Args = appendIfMissing(os.Args, "--no-zygote")38 os.Args = appendIfMissing(os.Args, "--no-zygote")
41 39
42 eng := &Engine{40 eng := &Engine{
43- ctx: ctx,41+ ctx: ctx,
44- multiThreadedMessageLoop: multiThreaded,
45- manualPumpInterval: manualPumpMs,
46 }42 }
47 43
48 logger.Info().44 logger.Info().
49- Bool("multi_threaded_message_loop", multiThreaded).
50- Int64("manual_pump_interval_ms", manualPumpMs).
51 Int32("windowless_frame_rate", windowlessFrameRate).45 Int32("windowless_frame_rate", windowlessFrameRate).
46+ Bool("external_begin_frame", externalBeginFrameEnabled()).
52 Bool("trace_handlers", cfg.TraceHandlers).47 Bool("trace_handlers", cfg.TraceHandlers).
53 Bool("enable_audio_handler", cfg.EnableAudioHandler).48 Bool("enable_audio_handler", cfg.EnableAudioHandler).
54 Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).49 Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
55 Msg("cef: configured engine")50 Msg("cef: configured engine")
56 51
57- if err := initializeCEF(eng, settings, multiThreaded, logger); err != nil {52+ if err := initializeCEF(eng, settings, logger); err != nil {
58 os.Args = savedArgs53 os.Args = savedArgs
59 return nil, err54 return nil, err
60 }55 }
@@ -66,7 +61,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
66 // prepareCEFSettings builds purecef.Settings from the engine config.61 // prepareCEFSettings builds purecef.Settings from the engine config.
67 func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings {62 func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings {
68 settings := purecef.DefaultSettings()63 settings := purecef.DefaultSettings()
69- settings.MultiThreadedMessageLoop = cfg.CEFMultiThreadedMessageLoop()64+ settings.MultiThreadedMessageLoop = true
70 settings.ExternalMessagePump = false65 settings.ExternalMessagePump = false
71 if cfg.CEFDir != "" {66 if cfg.CEFDir != "" {
72 settings.CEFDir = cfg.CEFDir67 settings.CEFDir = cfg.CEFDir
@@ -97,22 +92,15 @@ func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) pure
97 return settings92 return settings
98 }93 }
99 94
100-// initializeCEF calls cef_initialize with or without an App depending on pump mode.95+// initializeCEF calls cef_initialize with the App to register custom schemes
101-func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, logger *zerolog.Logger) error {96+// and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
102- if multiThreaded {97+func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
103- logger.Debug().Msg("cef: calling Init")98+ app := newDumberApp(eng)
104- if err := purecef.Init(settings); err != nil {99+ logger.Debug().Msg("cef: calling InitWithApp")
105- return fmt.Errorf("cef.Init: %w", err)100+ if err := purecef.InitWithApp(settings, app); err != nil {
106- }101+ return fmt.Errorf("cef.InitWithApp: %w", err)
107- logger.Debug().Msg("cef: Init returned OK")
108- } else {
109- app := newDumberApp(eng)
110- logger.Debug().Msg("cef: calling InitWithApp")
111- if err := purecef.InitWithApp(settings, app); err != nil {
112- return fmt.Errorf("cef.InitWithApp: %w", err)
113- }
114- logger.Debug().Msg("cef: InitWithApp returned OK")
115 }102 }
103+ logger.Debug().Msg("cef: InitWithApp returned OK")
116 return nil104 return nil
117 }105 }
118 106
@@ -129,17 +117,6 @@ func wireEngine(
129 117
130 scale := detectHiDPIScale(logger)118 scale := detectHiDPIScale(logger)
131 119
132- // Auto-detect frame rate from monitor when config uses the default (0 = auto).
133- // CEFWindowlessFrameRate() returns 0 when the user hasn't explicitly set a value,
134- // allowing us to match the monitor's refresh rate for smoother scrolling.
135- if windowlessFrameRate == 0 {
136- if hz := detectMonitorRefreshRate(logger); hz > 0 {
137- windowlessFrameRate = hz
138- } else {
139- windowlessFrameRate = 60 // safe fallback
140- }
141- }
142-
143 // Initialize the GPU transcoder when enabled in the loaded app config.120 // Initialize the GPU transcoder when enabled in the loaded app config.
144 var mediaTranscoder port.MediaTranscoder121 var mediaTranscoder port.MediaTranscoder
145 transcoderState := buildTranscoderStartupState(transcodingCfg)122 transcoderState := buildTranscoderStartupState(transcodingCfg)
@@ -428,10 +405,5 @@ func prepareCEFInitTraceFile(cfg config.CEFEngineConfig) (string, error) {
428 }405 }
429 406
430 func puregoCEFInitTraceEnabled() bool {407 func puregoCEFInitTraceEnabled() bool {
431- switch strings.ToLower(strings.TrimSpace(os.Getenv(puregoCEFInitTraceEnvVar))) {408+ return envBoolEnabled(puregoCEFInitTraceEnvVar)
432- case "1", "true", "yes", "on":
433- return true
434- default:
435- return false
436- }
437 }409 }
A internal/infrastructure/cef/env.go +23 -0
@@ -0,0 +1,23 @@
1+package cef
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"
9+
10+// envBoolEnabled returns true when the given environment variable is set
11+// to a truthy value ("1", "true", "yes", "on").
12+func envBoolEnabled(envVar string) bool {
13+ switch strings.ToLower(strings.TrimSpace(os.Getenv(envVar))) {
14+ case "1", "true", "yes", "on":
15+ return true
16+ default:
17+ return false
18+ }
19+}
20+
21+func externalBeginFrameEnabled() bool {
22+ return envBoolEnabled(cefExternalBeginFrameEnvVar)
23+}
@@ -0,0 +1,23 @@
1+package cef
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"
9+
10+// envBoolEnabled returns true when the given environment variable is set
11+// to a truthy value ("1", "true", "yes", "on").
12+func envBoolEnabled(envVar string) bool {
13+ switch strings.ToLower(strings.TrimSpace(os.Getenv(envVar))) {
14+ case "1", "true", "yes", "on":
15+ return true
16+ default:
17+ return false
18+ }
19+}
20+
21+func externalBeginFrameEnabled() bool {
22+ return envBoolEnabled(cefExternalBeginFrameEnvVar)
23+}
M internal/infrastructure/cef/factory.go +3 -1
@@ -120,7 +120,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
120120 // Configure WindowInfo for off-screen rendering (OSR).
121121 windowInfo := purecef.NewWindowInfo()
122122 windowInfo.WindowlessRenderingEnabled = 1
123- windowInfo.ExternalBeginFrameEnabled = 1
123+ if externalBeginFrameEnabled() {
124+ windowInfo.ExternalBeginFrameEnabled = 1
125+ }
124126
125127 // Configure BrowserSettings.
126128 settings := purecef.NewBrowserSettings()
@@ -120,7 +120,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
120 // Configure WindowInfo for off-screen rendering (OSR).120 // Configure WindowInfo for off-screen rendering (OSR).
121 windowInfo := purecef.NewWindowInfo()121 windowInfo := purecef.NewWindowInfo()
122 windowInfo.WindowlessRenderingEnabled = 1122 windowInfo.WindowlessRenderingEnabled = 1
123- windowInfo.ExternalBeginFrameEnabled = 1123+ if externalBeginFrameEnabled() {
124+ windowInfo.ExternalBeginFrameEnabled = 1
125+ }
124 126
125 // Configure BrowserSettings.127 // Configure BrowserSettings.
126 settings := purecef.NewBrowserSettings()128 settings := purecef.NewBrowserSettings()
M internal/infrastructure/cef/find_controller.go +0 -3
@@ -43,7 +43,6 @@ func (fc *cefFindController) setHost(host purecef.BrowserHost) {
4343 fc.mu.Unlock()
4444 }
4545
46-// Search starts a new find-in-page search.
4746 func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {
4847 fc.mu.Lock()
4948 fc.searchTxt = text
@@ -103,7 +102,6 @@ func (fc *cefFindController) SearchPrevious() {
103102 host.Find(text, 0, mc, 1) // forward=0, findnext=1
104103 }
105104
106-// SearchFinish stops the current search and clears the highlight.
107105 func (fc *cefFindController) SearchFinish() {
108106 fc.mu.Lock()
109107 host := fc.host
@@ -115,7 +113,6 @@ func (fc *cefFindController) SearchFinish() {
115113 }
116114 }
117115
118-// GetSearchText returns the current search text.
119116 func (fc *cefFindController) GetSearchText() string {
120117 fc.mu.RLock()
121118 defer fc.mu.RUnlock()
@@ -43,7 +43,6 @@ func (fc *cefFindController) setHost(host purecef.BrowserHost) {
43 fc.mu.Unlock()43 fc.mu.Unlock()
44 }44 }
45 45
46-// Search starts a new find-in-page search.
47 func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {46 func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {
48 fc.mu.Lock()47 fc.mu.Lock()
49 fc.searchTxt = text48 fc.searchTxt = text
@@ -103,7 +102,6 @@ func (fc *cefFindController) SearchPrevious() {
103 host.Find(text, 0, mc, 1) // forward=0, findnext=1102 host.Find(text, 0, mc, 1) // forward=0, findnext=1
104 }103 }
105 104
106-// SearchFinish stops the current search and clears the highlight.
107 func (fc *cefFindController) SearchFinish() {105 func (fc *cefFindController) SearchFinish() {
108 fc.mu.Lock()106 fc.mu.Lock()
109 host := fc.host107 host := fc.host
@@ -115,7 +113,6 @@ func (fc *cefFindController) SearchFinish() {
115 }113 }
116 }114 }
117 115
118-// GetSearchText returns the current search text.
119 func (fc *cefFindController) GetSearchText() string {116 func (fc *cefFindController) GetSearchText() string {
120 fc.mu.RLock()117 fc.mu.RLock()
121 defer fc.mu.RUnlock()118 defer fc.mu.RUnlock()
M internal/infrastructure/cef/messaging.go +5 -41
@@ -14,29 +14,11 @@ import (
1414 // Compile-time check: MessageRouter implements port.WebUIHandlerRouter.
1515 var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
1616
17-// Message represents a JS -> Go message envelope sent via fetch to /api/message.
18-type Message struct {
19- Type string `json:"type"`
20- Payload json.RawMessage `json:"payload"`
21- WebViewID uint64 `json:"webview_id,omitempty"`
22- WebViewIDAlt uint64 `json:"webviewId,omitempty"`
23-}
24-
25-// MessageHandler handles a decoded message payload.
26-type MessageHandler interface {
27- Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
28-}
29-
30-// MessageHandlerFunc adapts a function to the MessageHandler interface.
31-type MessageHandlerFunc func(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
32-
33-// Handle calls f(ctx, webviewID, payload).
34-func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
35- return f(ctx, webviewID, payload)
36-}
17+// Message is the JS -> Go message envelope sent via fetch to /api/message.
18+type Message = port.WebUIMessage
3719
3820 type handlerEntry struct {
39- handler MessageHandler
21+ handler portHandlerAdapter
4022 callback string
4123 errorCallback string
4224 world string
@@ -60,7 +42,6 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
6042 }
6143 }
6244
63-// SetBaseContext updates the base context used for handler execution.
6445 func (r *MessageRouter) SetBaseContext(ctx context.Context) {
6546 if r == nil {
6647 return
@@ -115,24 +96,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(
11596 return nil
11697 }
11798
118-// registerInternalHandler registers a CEF-internal handler that works with bare
119-// uint64 webview IDs (e.g. accent key handlers in engine.go). External callers
120-// should use RegisterHandler/RegisterHandlerWithCallbacks (port.WebUIHandlerRouter).
121-func (r *MessageRouter) registerInternalHandler(msgType string, handler MessageHandler) error {
122- if msgType == "" {
123- return errors.New("message type cannot be empty")
124- }
125- if handler == nil {
126- return errors.New("message handler cannot be nil")
127- }
128- r.mu.Lock()
129- defer r.mu.Unlock()
130- r.handlers[msgType] = handlerEntry{handler: handler}
131- return nil
132-}
133-
134-// portHandlerAdapter wraps a port.WebUIMessageHandler as a cef.MessageHandler,
135-// converting the WebViewID type.
99+// portHandlerAdapter wraps a port.WebUIMessageHandler, converting the WebViewID type.
136100 type portHandlerAdapter struct {
137101 inner port.WebUIMessageHandler
138102 }
@@ -167,7 +131,7 @@ func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, bod
167131 entry, ok := r.handlers[msg.Type]
168132 r.mu.RUnlock()
169133
170- if !ok || entry.handler == nil {
134+ if !ok || entry.handler.inner == nil {
171135 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
172136 return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
173137 }
@@ -14,29 +14,11 @@ import (
14 // Compile-time check: MessageRouter implements port.WebUIHandlerRouter.14 // Compile-time check: MessageRouter implements port.WebUIHandlerRouter.
15 var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)15 var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
16 16
17-// Message represents a JS -> Go message envelope sent via fetch to /api/message.17+// Message is the JS -> Go message envelope sent via fetch to /api/message.
18-type Message struct {18+type Message = port.WebUIMessage
19- Type string `json:"type"`
20- Payload json.RawMessage `json:"payload"`
21- WebViewID uint64 `json:"webview_id,omitempty"`
22- WebViewIDAlt uint64 `json:"webviewId,omitempty"`
23-}
24-
25-// MessageHandler handles a decoded message payload.
26-type MessageHandler interface {
27- Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
28-}
29-
30-// MessageHandlerFunc adapts a function to the MessageHandler interface.
31-type MessageHandlerFunc func(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
32-
33-// Handle calls f(ctx, webviewID, payload).
34-func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
35- return f(ctx, webviewID, payload)
36-}
37 19
38 type handlerEntry struct {20 type handlerEntry struct {
39- handler MessageHandler21+ handler portHandlerAdapter
40 callback string22 callback string
41 errorCallback string23 errorCallback string
42 world string24 world string
@@ -60,7 +42,6 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
60 }42 }
61 }43 }
62 44
63-// SetBaseContext updates the base context used for handler execution.
64 func (r *MessageRouter) SetBaseContext(ctx context.Context) {45 func (r *MessageRouter) SetBaseContext(ctx context.Context) {
65 if r == nil {46 if r == nil {
66 return47 return
@@ -115,24 +96,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(
115 return nil96 return nil
116 }97 }
117 98
118-// registerInternalHandler registers a CEF-internal handler that works with bare99+// portHandlerAdapter wraps a port.WebUIMessageHandler, converting the WebViewID type.
119-// uint64 webview IDs (e.g. accent key handlers in engine.go). External callers
120-// should use RegisterHandler/RegisterHandlerWithCallbacks (port.WebUIHandlerRouter).
121-func (r *MessageRouter) registerInternalHandler(msgType string, handler MessageHandler) error {
122- if msgType == "" {
123- return errors.New("message type cannot be empty")
124- }
125- if handler == nil {
126- return errors.New("message handler cannot be nil")
127- }
128- r.mu.Lock()
129- defer r.mu.Unlock()
130- r.handlers[msgType] = handlerEntry{handler: handler}
131- return nil
132-}
133-
134-// portHandlerAdapter wraps a port.WebUIMessageHandler as a cef.MessageHandler,
135-// converting the WebViewID type.
136 type portHandlerAdapter struct {100 type portHandlerAdapter struct {
137 inner port.WebUIMessageHandler101 inner port.WebUIMessageHandler
138 }102 }
@@ -167,7 +131,7 @@ func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, bod
167 entry, ok := r.handlers[msg.Type]131 entry, ok := r.handlers[msg.Type]
168 r.mu.RUnlock()132 r.mu.RUnlock()
169 133
170- if !ok || entry.handler == nil {134+ if !ok || entry.handler.inner == nil {
171 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")135 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
172 return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})136 return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
173 }137 }
M internal/infrastructure/cef/pool.go +0 -1
@@ -104,7 +104,6 @@ func (p *WebViewPool) PrewarmAsync(_ context.Context, count int) {
104104 p.Prewarm(count)
105105 }
106106
107-// Size returns the number of available WebViews currently in the pool.
108107 func (p *WebViewPool) Size() int {
109108 p.mu.Lock()
110109 defer p.mu.Unlock()
@@ -104,7 +104,6 @@ func (p *WebViewPool) PrewarmAsync(_ context.Context, count int) {
104 p.Prewarm(count)104 p.Prewarm(count)
105 }105 }
106 106
107-// Size returns the number of available WebViews currently in the pool.
108 func (p *WebViewPool) Size() int {107 func (p *WebViewPool) Size() int {
109 p.mu.Lock()108 p.mu.Lock()
110 defer p.mu.Unlock()109 defer p.mu.Unlock()
M internal/infrastructure/cef/render_pipeline.go +275 -88
@@ -20,8 +20,8 @@ type rect struct {
2020 // off-screen BGRA pixel buffers into a GtkGLArea widget.
2121 //
2222 // CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint
23-// copies dirty regions into a CPU-side staging buffer and queues a GTK render.
24-// The GL render signal uploads from staging → PBO → texture, then draws a
23+// copies dirty regions into a CPU-side staging buffer on the CEF UI thread,
24+// and GTK later uploads from staging → PBO → texture before drawing a
2525 // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
2626 type renderPipeline struct {
2727 ctx context.Context
@@ -38,20 +38,18 @@ type renderPipeline struct {
3838 vbo uint32
3939
4040 // Surface dimensions (in device pixels, i.e. scaled).
41- width int32
42- height int32
43- scale int32
44-
45- // geometry mirrors the current surface size for lock-free CEF callbacks.
41+ // Atomics allow lock-free reads from CEF callbacks (viewRectSize).
4642 widthAtomic atomic.Int32
4743 heightAtomic atomic.Int32
44+ scale int32
4845
4946 // Staging: handlePaint copies dirty rects here, render signal uploads.
50- mu sync.Mutex
51- staging []byte
52- dirtyRects []rect
53- needsUpload bool
54- sizeChanged bool
47+ mu sync.Mutex
48+ staging []byte
49+ dirtyRects []rect
50+ needsUpload bool
51+ sizeChanged bool
52+ forceFullUpload bool
5553
5654 // Popup staging for native CEF widgets like <select> menus.
5755 popupVisible bool
@@ -63,9 +61,12 @@ type renderPipeline struct {
6361 popupSizeChanged bool
6462
6563 // GL initialized flag.
66- glReady bool
64+ glReady bool
65+ glWidth int32
66+ glHeight int32
6767
68- // Diagnostics (main thread only).
68+ // Diagnostics can be updated from both the CEF UI thread and GTK thread.
69+ diagMu sync.Mutex
6970 viewRectSeq atomic.Uint64
7071 screenInfoSeq atomic.Uint64
7172 paintSeq atomic.Uint64
@@ -168,9 +169,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi
168169 return rp
169170 }
170171
171-// handlePaint is called from CEF's OnPaint callback (on GTK main thread via
172-// IdleAdd). It copies dirty rect regions from the CEF buffer into the staging
173-// buffer and queues a GL redraw.
172+// handlePaint is called from CEF's OnPaint callback. It copies dirty rect
173+// regions from the transient CEF buffer into the persistent staging buffer and
174+// marks the next GTK render for upload.
174175 func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
175176 if len(buffer) == 0 || width <= 0 || height <= 0 {
176177 return
@@ -198,13 +199,12 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
198199 sizeChanged := false
199200
200201 // Detect size change, or first paint (staging not yet allocated).
201- if stagingNeedsReset(rp.staging, rp.width, rp.height, width, height, bufSize) {
202- rp.width = width
203- rp.height = height
202+ if stagingNeedsReset(rp.staging, rp.widthAtomic.Load(), rp.heightAtomic.Load(), width, height, bufSize) {
204203 rp.widthAtomic.Store(width)
205204 rp.heightAtomic.Store(height)
206205 rp.staging = make([]byte, bufSize)
207206 rp.sizeChanged = true
207+ rp.forceFullUpload = true
208208 sizeChanged = true
209209 // On size change, copy the entire buffer.
210210 copiedBytes = uint64(copy(rp.staging, buffer))
@@ -228,21 +228,22 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
228228 // next GL render would lose their dirty rect metadata — the pixels are
229229 // in staging but never uploaded to the texture, causing trails/ghosts.
230230 rp.dirtyRects = append(rp.dirtyRects, rects...)
231+ rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load())
231232 rp.needsUpload = true
232233
233234 rp.mu.Unlock()
234235
235236 copyDuration := time.Since(startedAt)
237+ rp.diagMu.Lock()
236238 rp.paintCount++
237- rp.queueRenderCount++
238239 rp.paintBytes += copiedBytes
239240 rp.dirtyRectCount += uint64(len(rects))
240241 rp.paintCopyTotalNs += uint64(copyDuration)
241242 if copyDuration.Nanoseconds() > rp.maxPaintCopyNs {
242243 rp.maxPaintCopyNs = copyDuration.Nanoseconds()
243244 }
245+ rp.diagMu.Unlock()
244246
245- rp.glArea.QueueRender()
246247 if rp.ctx != nil {
247248 if sizeChanged && copiedBytes < uint64(bufSize) {
248249 logging.FromContext(rp.ctx).Warn().
@@ -263,6 +264,13 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
263264 rp.maybeLogDiagnostics()
264265 }
265266
267+func (rp *renderPipeline) queuePaintRender() {
268+ rp.diagMu.Lock()
269+ rp.queueRenderCount++
270+ rp.diagMu.Unlock()
271+ rp.glArea.QueueRender()
272+}
273+
266274 // onGLRender is the GTK "render" signal handler. GL context is current.
267275 func (rp *renderPipeline) onGLRender() bool {
268276 renderSeq := rp.glRenderSeq.Add(1)
@@ -278,47 +286,46 @@ func (rp *renderPipeline) onGLRender() bool {
278286
279287 needsUpload := rp.needsUpload
280288 sizeChanged := rp.sizeChanged
281- rp.needsUpload = false
282-
283- // Snapshot dirty rects for upload and clear the accumulator.
284- var dirtyRects []rect
285- if needsUpload {
286- dirtyRects = make([]rect, len(rp.dirtyRects))
287- copy(dirtyRects, rp.dirtyRects)
288- rp.dirtyRects = rp.dirtyRects[:0]
289- }
289+ uploadWidth := rp.widthAtomic.Load()
290+ uploadHeight := rp.heightAtomic.Load()
290291
291292 rp.mu.Unlock()
292293
293294 gl := rp.gl
294295
295296 if !rp.glReady || sizeChanged {
296- rp.initGL()
297+ rp.initGL(uploadWidth, uploadHeight)
297298 }
298299
299300 if !rp.glReady {
300- rp.glRenderCount++
301301 renderDuration := time.Since(renderStartedAt)
302+ rp.diagMu.Lock()
303+ rp.glRenderCount++
302304 rp.renderTotalNs += uint64(renderDuration)
303305 if renderDuration.Nanoseconds() > rp.maxRenderNs {
304306 rp.maxRenderNs = renderDuration.Nanoseconds()
305307 }
308+ rp.diagMu.Unlock()
306309 rp.maybeLogDiagnostics()
307310 return true
308311 }
309312
310313 if needsUpload {
311314 uploadStartedAt := time.Now()
312- uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq)
313- uploadDuration := time.Since(uploadStartedAt)
314- rp.uploadCount++
315- if sizeChanged {
316- rp.fullUploadCount++
317- }
318- rp.uploadBytes += uploadedBytes
319- rp.uploadTotalNs += uint64(uploadDuration)
320- if uploadDuration.Nanoseconds() > rp.maxUploadNs {
321- rp.maxUploadNs = uploadDuration.Nanoseconds()
315+ uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq)
316+ if didUpload {
317+ uploadDuration := time.Since(uploadStartedAt)
318+ rp.diagMu.Lock()
319+ rp.uploadCount++
320+ if fullUpload {
321+ rp.fullUploadCount++
322+ }
323+ rp.uploadBytes += uploadedBytes
324+ rp.uploadTotalNs += uint64(uploadDuration)
325+ if uploadDuration.Nanoseconds() > rp.maxUploadNs {
326+ rp.maxUploadNs = uploadDuration.Nanoseconds()
327+ }
328+ rp.diagMu.Unlock()
322329 }
323330 }
324331
@@ -333,12 +340,14 @@ func (rp *renderPipeline) onGLRender() bool {
333340 rp.drawPopupOverlay(w, h)
334341 }
335342
336- rp.glRenderCount++
337343 renderDuration := time.Since(renderStartedAt)
344+ rp.diagMu.Lock()
345+ rp.glRenderCount++
338346 rp.renderTotalNs += uint64(renderDuration)
339347 if renderDuration.Nanoseconds() > rp.maxRenderNs {
340348 rp.maxRenderNs = renderDuration.Nanoseconds()
341349 }
350+ rp.diagMu.Unlock()
342351 rp.maybeLogDiagnostics()
343352 if rp.ctx != nil {
344353 logging.FromContext(rp.ctx).Trace().
@@ -368,19 +377,27 @@ func (rp *renderPipeline) drawTexture(texture uint32) {
368377 }
369378
370379 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
371-// transfers to the GL texture via async DMA.
372-func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {
380+// transfers to the GL texture via async DMA. The mutex serializes CEF-thread
381+// staging writes against GTK-thread PBO copies so the upload sees a consistent
382+// staging buffer and dirty-rect set.
383+func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, bool) {
373384 gl := rp.gl
374385
375- w, h, _ := rp.viewRectSize()
386+ rp.mu.Lock()
387+ w := rp.glWidth
388+ h := rp.glHeight
389+ needsUpload := rp.needsUpload
390+ rp.mu.Unlock()
391+ if !needsUpload || w <= 0 || h <= 0 {
392+ return 0, false, false
393+ }
394+
376395 if rp.ctx != nil {
377396 logging.FromContext(rp.ctx).Trace().
378397 Uint64("render_seq", renderSeq).
379398 Uint64("paint_seq", paintSeq).
380399 Int32("width", w).
381400 Int32("height", h).
382- Int("dirty_rect_count", len(dirtyRects)).
383- Bool("full_upload", fullUpload).
384401 Msg("cef: uploadToPBO begin")
385402 }
386403
@@ -395,13 +412,42 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
395412 mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)
396413 if mapped == nil {
397414 gl.bindBuffer(glPixelUnpackBuffer, 0)
398- return 0
415+ return 0, false, false
399416 }
400417
401418 mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize))
402419 uploadedBytes := uint64(0)
420+ fullUpload := false
421+ dirtyRects := make([]rect, 0)
403422
404423 rp.mu.Lock()
424+ if !rp.needsUpload {
425+ rp.mu.Unlock()
426+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
427+ gl.bindBuffer(glPixelUnpackBuffer, 0)
428+ return 0, false, false
429+ }
430+ gl.bindBuffer(glPixelUnpackBuffer, 0)
431+ return 0, false, false
432+ }
433+
434+ if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight {
435+ rp.mu.Unlock()
436+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
437+ gl.bindBuffer(glPixelUnpackBuffer, 0)
438+ return 0, false, false
439+ }
440+ gl.bindBuffer(glPixelUnpackBuffer, 0)
441+ if rp.ctx != nil {
442+ logging.FromContext(rp.ctx).Trace().
443+ Uint64("render_seq", renderSeq).
444+ Uint64("paint_seq", paintSeq).
445+ Msg("cef: skipping upload while newer surface resize is pending")
446+ }
447+ return 0, false, false
448+ }
449+
450+ fullUpload = rp.forceFullUpload
405451 if fullUpload {
406452 copied := copy(mappedSlice, rp.staging)
407453 if copied < len(mappedSlice) {
@@ -409,19 +455,12 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
409455 }
410456 uploadedBytes = uint64(copied)
411457 } else {
412- for _, rawRect := range dirtyRects {
413- r, ok := clampDirtyRect(rawRect, w, h)
414- if !ok {
415- continue
416- }
417- for row := r.Y; row < r.Y+r.Height; row++ {
418- off := int(row)*stride + int(r.X)*4
419- rowBytes := int(r.Width) * 4
420- copy(mappedSlice[off:off+rowBytes], rp.staging[off:off+rowBytes])
421- uploadedBytes += uint64(rowBytes)
422- }
423- }
458+ dirtyRects = append(dirtyRects, rp.dirtyRects...)
459+ uploadedBytes = copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects)
424460 }
461+ rp.needsUpload = false
462+ rp.forceFullUpload = false
463+ rp.dirtyRects = rp.dirtyRects[:0]
425464 rp.mu.Unlock()
426465
427466 if !gl.unmapBuffer(glPixelUnpackBuffer) {
@@ -433,7 +472,7 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
433472 Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
434473 }
435474 gl.bindBuffer(glPixelUnpackBuffer, 0)
436- return 0
475+ return 0, fullUpload, false
437476 }
438477
439478 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.
@@ -467,10 +506,12 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
467506 Uint64("render_seq", renderSeq).
468507 Uint64("paint_seq", paintSeq).
469508 Uint64("uploaded_bytes", uploadedBytes).
509+ Int("dirty_rect_count", len(dirtyRects)).
510+ Bool("full_upload", fullUpload).
470511 Int("next_pbo_index", rp.pboIndex).
471512 Msg("cef: uploadToPBO end")
472513 }
473- return uploadedBytes
514+ return uploadedBytes, fullUpload, true
474515 }
475516
476517 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
@@ -577,15 +618,9 @@ func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
577618 rp.gl.viewport(0, 0, viewWidth, viewHeight)
578619 }
579620
580-// initGL creates or recreates all GL resources for the current surface size.
581-func (rp *renderPipeline) initGL() {
582- rp.mu.Lock()
583- w := rp.width
584- h := rp.height
585- rp.sizeChanged = false
586- rp.mu.Unlock()
587-
588- if w <= 0 || h <= 0 {
621+// initGL creates or recreates all GL resources for the provided surface size.
622+func (rp *renderPipeline) initGL(width, height int32) {
623+ if width <= 0 || height <= 0 {
589624 return
590625 }
591626
@@ -596,6 +631,15 @@ func (rp *renderPipeline) initGL() {
596631 rp.deleteGLResources()
597632 }
598633
634+ rp.mu.Lock()
635+ if rp.widthAtomic.Load() == width && rp.heightAtomic.Load() == height {
636+ rp.sizeChanged = false
637+ }
638+ rp.forceFullUpload = true
639+ rp.glWidth = width
640+ rp.glHeight = height
641+ rp.mu.Unlock()
642+
599643 // Texture.
600644 gl.genTextures(1, &rp.texture)
601645 gl.bindTexture(glTexture2D, rp.texture)
@@ -603,11 +647,11 @@ func (rp *renderPipeline) initGL() {
603647 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
604648 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
605649 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
606- gl.texImage2D(glTexture2D, 0, int32(glRGBA), w, h, 0, glBGRA, glUnsignedByte, nil)
650+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), width, height, 0, glBGRA, glUnsignedByte, nil)
607651 gl.bindTexture(glTexture2D, 0)
608652
609653 // PBOs.
610- bufSize := int64(w) * int64(h) * 4
654+ bufSize := int64(width) * int64(height) * 4
611655 gl.genBuffers(1, &rp.pbo[0])
612656 gl.genBuffers(1, &rp.pbo[1])
613657 for i := 0; i < 2; i++ {
@@ -650,12 +694,18 @@ func (rp *renderPipeline) initGL() {
650694 }
651695
652696 func (rp *renderPipeline) recordAcceleratedPaint() uint64 {
697+ rp.diagMu.Lock()
653698 rp.acceleratedPaintCount++
699+ count := rp.acceleratedPaintCount
700+ rp.diagMu.Unlock()
654701 rp.maybeLogDiagnostics()
655- return rp.acceleratedPaintCount
702+ return count
656703 }
657704
658705 func (rp *renderPipeline) maybeLogDiagnostics() {
706+ rp.diagMu.Lock()
707+ defer rp.diagMu.Unlock()
708+
659709 now := time.Now()
660710 if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second {
661711 return
@@ -777,10 +827,9 @@ func (rp *renderPipeline) onResize(width, height int32) {
777827 return v * rp.scale
778828 }
779829
780- rp.width = scaled(width)
781- rp.height = scaled(height)
782- rp.widthAtomic.Store(rp.width)
783- rp.heightAtomic.Store(rp.height)
830+ w, h := scaled(width), scaled(height)
831+ rp.widthAtomic.Store(w)
832+ rp.heightAtomic.Store(h)
784833 rp.sizeChanged = true
785834
786835 firstCB := rp.onFirstResize
@@ -788,8 +837,6 @@ func (rp *renderPipeline) onResize(width, height int32) {
788837 rp.onFirstResize = nil
789838 }
790839 resizeCB := rp.onResizeCB
791-
792- w, h := rp.width, rp.height
793840 rp.mu.Unlock()
794841
795842 if firstCB != nil {
@@ -866,12 +913,6 @@ func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, p
866913 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
867914 width = rp.widthAtomic.Load()
868915 height = rp.heightAtomic.Load()
869- if width <= 0 || height <= 0 {
870- rp.mu.Lock()
871- width = rp.width
872- height = rp.height
873- rp.mu.Unlock()
874- }
875916 scale = rp.scale
876917 if scale <= 0 {
877918 scale = 1
@@ -953,6 +994,8 @@ func (rp *renderPipeline) deleteGLResources() {
953994 // glDeleteProgram to glLoader if needed.
954995 rp.program = 0
955996 }
997+ rp.glWidth = 0
998+ rp.glHeight = 0
956999 }
9571000
9581001 func bgraBufferSize(width, height int32) (int, bool) {
@@ -966,6 +1009,88 @@ func bgraBufferSize(width, height int32) (int, bool) {
9661009 return int(size64), true
9671010 }
9681011
1012+// coalesceDirtyRects merges overlapping or edge-adjacent dirty rects to reduce
1013+// redundant row-by-row PBO copies. If the total dirty area exceeds 60% of the
1014+// surface, it collapses to a single full-surface rect.
1015+//
1016+// This is called inside handlePaint's mu.Lock() section so it must be fast for
1017+// small n. O(n^2) is fine since n is typically < 20.
1018+func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect {
1019+ if len(rects) <= 1 {
1020+ return rects
1021+ }
1022+
1023+ // Check if total dirty area exceeds 60% of the surface — if so,
1024+ // a single full-surface upload is cheaper than per-rect copies.
1025+ totalArea := int64(0)
1026+ surfaceArea := int64(surfaceW) * int64(surfaceH)
1027+ for _, r := range rects {
1028+ totalArea += int64(r.Width) * int64(r.Height)
1029+ }
1030+ if surfaceArea > 0 && totalArea*100 > surfaceArea*60 {
1031+ return []rect{{0, 0, surfaceW, surfaceH}}
1032+ }
1033+
1034+ // Merge overlapping or edge-adjacent rects. Two rects touch if their
1035+ // X ranges overlap-or-touch AND Y ranges overlap-or-touch. When merged,
1036+ // both are replaced with their bounding box. Repeat until stable.
1037+ merged := make([]rect, len(rects))
1038+ copy(merged, rects)
1039+
1040+ changed := true
1041+ for changed {
1042+ changed = false
1043+ for i := 0; i < len(merged); i++ {
1044+ for j := i + 1; j < len(merged); j++ {
1045+ if rectsTouch(merged[i], merged[j]) {
1046+ merged[i] = boundingBox(merged[i], merged[j])
1047+ // Remove j by swapping with last element.
1048+ merged[j] = merged[len(merged)-1]
1049+ merged = merged[:len(merged)-1]
1050+ changed = true
1051+ j-- // re-check the element now at index j
1052+ }
1053+ }
1054+ }
1055+ }
1056+
1057+ return merged
1058+}
1059+
1060+// rectsTouch returns true if two rects overlap or share an edge (adjacent).
1061+func rectsTouch(a, b rect) bool {
1062+ // a's right edge <= b's left edge → no horizontal contact
1063+ if a.X+a.Width < b.X || b.X+b.Width < a.X {
1064+ return false
1065+ }
1066+ // a's bottom edge <= b's top edge → no vertical contact
1067+ if a.Y+a.Height < b.Y || b.Y+b.Height < a.Y {
1068+ return false
1069+ }
1070+ return true
1071+}
1072+
1073+// boundingBox returns the smallest rect containing both a and b.
1074+func boundingBox(a, b rect) rect {
1075+ x := a.X
1076+ if b.X < x {
1077+ x = b.X
1078+ }
1079+ y := a.Y
1080+ if b.Y < y {
1081+ y = b.Y
1082+ }
1083+ right := a.X + a.Width
1084+ if br := b.X + b.Width; br > right {
1085+ right = br
1086+ }
1087+ bottom := a.Y + a.Height
1088+ if bb := b.Y + b.Height; bb > bottom {
1089+ bottom = bb
1090+ }
1091+ return rect{X: x, Y: y, Width: right - x, Height: bottom - y}
1092+}
1093+
9691094 func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
9701095 return staging == nil ||
9711096 len(staging) != expectedBytes ||
@@ -1038,6 +1163,68 @@ func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rec
10381163 return copiedBytes, sanitized, truncated
10391164 }
10401165
1166+// copyDirtyRectsRaw copies only the rows covered by dirty rects from src into
1167+// dst at matching offsets. Both src and dst must be BGRA buffers of dimensions
1168+// width x height. Unlike copyDirtyRectsIntoStaging, the destination buffer's
1169+// existing data outside dirty rect rows is left untouched — the caller must
1170+// ensure that only the copied rows will be read downstream.
1171+//
1172+// On a full-surface dirty rect (e.g. resize), this degrades to copying all
1173+// rows — equivalent to a full buffer copy.
1174+//
1175+// Returns the total bytes copied.
1176+func copyDirtyRectsRaw(dst, src []byte, width, height int32, rects []rect) uint64 {
1177+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1178+ return 0
1179+ }
1180+
1181+ stride := int(width) * 4
1182+ copiedBytes := uint64(0)
1183+
1184+ for _, rawRect := range rects {
1185+ r, ok := clampDirtyRect(rawRect, width, height)
1186+ if !ok {
1187+ continue
1188+ }
1189+
1190+ for row := r.Y; row < r.Y+r.Height; row++ {
1191+ dstOff := int(row)*stride + int(r.X)*4
1192+ if dstOff < 0 || dstOff >= len(dst) {
1193+ break
1194+ }
1195+
1196+ rowBytes := int(r.Width) * 4
1197+ dstEnd := dstOff + rowBytes
1198+ if dstEnd > len(dst) {
1199+ dstEnd = len(dst)
1200+ }
1201+
1202+ srcOff := int(row)*stride + int(r.X)*4
1203+ if srcOff < 0 || srcOff >= len(src) {
1204+ continue
1205+ }
1206+
1207+ srcEnd := srcOff + rowBytes
1208+ if srcEnd > len(src) {
1209+ srcEnd = len(src)
1210+ }
1211+
1212+ copyLen := srcEnd - srcOff
1213+ maxDstLen := dstEnd - dstOff
1214+ if copyLen > maxDstLen {
1215+ copyLen = maxDstLen
1216+ }
1217+
1218+ if copyLen > 0 {
1219+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1220+ copiedBytes += uint64(copyLen)
1221+ }
1222+ }
1223+ }
1224+
1225+ return copiedBytes
1226+}
1227+
10411228 // pboOffset converts a byte offset into an unsafe.Pointer for use with
10421229 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
10431230 // byte offset into the bound buffer rather than a real memory address.
@@ -20,8 +20,8 @@ type rect struct {
20 // off-screen BGRA pixel buffers into a GtkGLArea widget.20 // off-screen BGRA pixel buffers into a GtkGLArea widget.
21 //21 //
22 // CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint22 // CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint
23-// copies dirty regions into a CPU-side staging buffer and queues a GTK render.23+// copies dirty regions into a CPU-side staging buffer on the CEF UI thread,
24-// The GL render signal uploads from staging → PBO → texture, then draws a24+// and GTK later uploads from staging → PBO → texture before drawing a
25 // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.25 // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
26 type renderPipeline struct {26 type renderPipeline struct {
27 ctx context.Context27 ctx context.Context
@@ -38,20 +38,18 @@ type renderPipeline struct {
38 vbo uint3238 vbo uint32
39 39
40 // Surface dimensions (in device pixels, i.e. scaled).40 // Surface dimensions (in device pixels, i.e. scaled).
41- width int3241+ // Atomics allow lock-free reads from CEF callbacks (viewRectSize).
42- height int32
43- scale int32
44-
45- // geometry mirrors the current surface size for lock-free CEF callbacks.
46 widthAtomic atomic.Int3242 widthAtomic atomic.Int32
47 heightAtomic atomic.Int3243 heightAtomic atomic.Int32
44+ scale int32
48 45
49 // Staging: handlePaint copies dirty rects here, render signal uploads.46 // Staging: handlePaint copies dirty rects here, render signal uploads.
50- mu sync.Mutex47+ mu sync.Mutex
51- staging []byte48+ staging []byte
52- dirtyRects []rect49+ dirtyRects []rect
53- needsUpload bool50+ needsUpload bool
54- sizeChanged bool51+ sizeChanged bool
52+ forceFullUpload bool
55 53
56 // Popup staging for native CEF widgets like <select> menus.54 // Popup staging for native CEF widgets like <select> menus.
57 popupVisible bool55 popupVisible bool
@@ -63,9 +61,12 @@ type renderPipeline struct {
63 popupSizeChanged bool61 popupSizeChanged bool
64 62
65 // GL initialized flag.63 // GL initialized flag.
66- glReady bool64+ glReady bool
65+ glWidth int32
66+ glHeight int32
67 67
68- // Diagnostics (main thread only).68+ // Diagnostics can be updated from both the CEF UI thread and GTK thread.
69+ diagMu sync.Mutex
69 viewRectSeq atomic.Uint6470 viewRectSeq atomic.Uint64
70 screenInfoSeq atomic.Uint6471 screenInfoSeq atomic.Uint64
71 paintSeq atomic.Uint6472 paintSeq atomic.Uint64
@@ -168,9 +169,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi
168 return rp169 return rp
169 }170 }
170 171
171-// handlePaint is called from CEF's OnPaint callback (on GTK main thread via172+// handlePaint is called from CEF's OnPaint callback. It copies dirty rect
172-// IdleAdd). It copies dirty rect regions from the CEF buffer into the staging173+// regions from the transient CEF buffer into the persistent staging buffer and
173-// buffer and queues a GL redraw.174+// marks the next GTK render for upload.
174 func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {175 func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
175 if len(buffer) == 0 || width <= 0 || height <= 0 {176 if len(buffer) == 0 || width <= 0 || height <= 0 {
176 return177 return
@@ -198,13 +199,12 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
198 sizeChanged := false199 sizeChanged := false
199 200
200 // Detect size change, or first paint (staging not yet allocated).201 // Detect size change, or first paint (staging not yet allocated).
201- if stagingNeedsReset(rp.staging, rp.width, rp.height, width, height, bufSize) {202+ if stagingNeedsReset(rp.staging, rp.widthAtomic.Load(), rp.heightAtomic.Load(), width, height, bufSize) {
202- rp.width = width
203- rp.height = height
204 rp.widthAtomic.Store(width)203 rp.widthAtomic.Store(width)
205 rp.heightAtomic.Store(height)204 rp.heightAtomic.Store(height)
206 rp.staging = make([]byte, bufSize)205 rp.staging = make([]byte, bufSize)
207 rp.sizeChanged = true206 rp.sizeChanged = true
207+ rp.forceFullUpload = true
208 sizeChanged = true208 sizeChanged = true
209 // On size change, copy the entire buffer.209 // On size change, copy the entire buffer.
210 copiedBytes = uint64(copy(rp.staging, buffer))210 copiedBytes = uint64(copy(rp.staging, buffer))
@@ -228,21 +228,22 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
228 // next GL render would lose their dirty rect metadata — the pixels are228 // next GL render would lose their dirty rect metadata — the pixels are
229 // in staging but never uploaded to the texture, causing trails/ghosts.229 // in staging but never uploaded to the texture, causing trails/ghosts.
230 rp.dirtyRects = append(rp.dirtyRects, rects...)230 rp.dirtyRects = append(rp.dirtyRects, rects...)
231+ rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load())
231 rp.needsUpload = true232 rp.needsUpload = true
232 233
233 rp.mu.Unlock()234 rp.mu.Unlock()
234 235
235 copyDuration := time.Since(startedAt)236 copyDuration := time.Since(startedAt)
237+ rp.diagMu.Lock()
236 rp.paintCount++238 rp.paintCount++
237- rp.queueRenderCount++
238 rp.paintBytes += copiedBytes239 rp.paintBytes += copiedBytes
239 rp.dirtyRectCount += uint64(len(rects))240 rp.dirtyRectCount += uint64(len(rects))
240 rp.paintCopyTotalNs += uint64(copyDuration)241 rp.paintCopyTotalNs += uint64(copyDuration)
241 if copyDuration.Nanoseconds() > rp.maxPaintCopyNs {242 if copyDuration.Nanoseconds() > rp.maxPaintCopyNs {
242 rp.maxPaintCopyNs = copyDuration.Nanoseconds()243 rp.maxPaintCopyNs = copyDuration.Nanoseconds()
243 }244 }
245+ rp.diagMu.Unlock()
244 246
245- rp.glArea.QueueRender()
246 if rp.ctx != nil {247 if rp.ctx != nil {
247 if sizeChanged && copiedBytes < uint64(bufSize) {248 if sizeChanged && copiedBytes < uint64(bufSize) {
248 logging.FromContext(rp.ctx).Warn().249 logging.FromContext(rp.ctx).Warn().
@@ -263,6 +264,13 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
263 rp.maybeLogDiagnostics()264 rp.maybeLogDiagnostics()
264 }265 }
265 266
267+func (rp *renderPipeline) queuePaintRender() {
268+ rp.diagMu.Lock()
269+ rp.queueRenderCount++
270+ rp.diagMu.Unlock()
271+ rp.glArea.QueueRender()
272+}
273+
266 // onGLRender is the GTK "render" signal handler. GL context is current.274 // onGLRender is the GTK "render" signal handler. GL context is current.
267 func (rp *renderPipeline) onGLRender() bool {275 func (rp *renderPipeline) onGLRender() bool {
268 renderSeq := rp.glRenderSeq.Add(1)276 renderSeq := rp.glRenderSeq.Add(1)
@@ -278,47 +286,46 @@ func (rp *renderPipeline) onGLRender() bool {
278 286
279 needsUpload := rp.needsUpload287 needsUpload := rp.needsUpload
280 sizeChanged := rp.sizeChanged288 sizeChanged := rp.sizeChanged
281- rp.needsUpload = false289+ uploadWidth := rp.widthAtomic.Load()
282-290+ uploadHeight := rp.heightAtomic.Load()
283- // Snapshot dirty rects for upload and clear the accumulator.
284- var dirtyRects []rect
285- if needsUpload {
286- dirtyRects = make([]rect, len(rp.dirtyRects))
287- copy(dirtyRects, rp.dirtyRects)
288- rp.dirtyRects = rp.dirtyRects[:0]
289- }
290 291
291 rp.mu.Unlock()292 rp.mu.Unlock()
292 293
293 gl := rp.gl294 gl := rp.gl
294 295
295 if !rp.glReady || sizeChanged {296 if !rp.glReady || sizeChanged {
296- rp.initGL()297+ rp.initGL(uploadWidth, uploadHeight)
297 }298 }
298 299
299 if !rp.glReady {300 if !rp.glReady {
300- rp.glRenderCount++
301 renderDuration := time.Since(renderStartedAt)301 renderDuration := time.Since(renderStartedAt)
302+ rp.diagMu.Lock()
303+ rp.glRenderCount++
302 rp.renderTotalNs += uint64(renderDuration)304 rp.renderTotalNs += uint64(renderDuration)
303 if renderDuration.Nanoseconds() > rp.maxRenderNs {305 if renderDuration.Nanoseconds() > rp.maxRenderNs {
304 rp.maxRenderNs = renderDuration.Nanoseconds()306 rp.maxRenderNs = renderDuration.Nanoseconds()
305 }307 }
308+ rp.diagMu.Unlock()
306 rp.maybeLogDiagnostics()309 rp.maybeLogDiagnostics()
307 return true310 return true
308 }311 }
309 312
310 if needsUpload {313 if needsUpload {
311 uploadStartedAt := time.Now()314 uploadStartedAt := time.Now()
312- uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq)315+ uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq)
313- uploadDuration := time.Since(uploadStartedAt)316+ if didUpload {
314- rp.uploadCount++317+ uploadDuration := time.Since(uploadStartedAt)
315- if sizeChanged {318+ rp.diagMu.Lock()
316- rp.fullUploadCount++319+ rp.uploadCount++
317- }320+ if fullUpload {
318- rp.uploadBytes += uploadedBytes321+ rp.fullUploadCount++
319- rp.uploadTotalNs += uint64(uploadDuration)322+ }
320- if uploadDuration.Nanoseconds() > rp.maxUploadNs {323+ rp.uploadBytes += uploadedBytes
321- rp.maxUploadNs = uploadDuration.Nanoseconds()324+ rp.uploadTotalNs += uint64(uploadDuration)
325+ if uploadDuration.Nanoseconds() > rp.maxUploadNs {
326+ rp.maxUploadNs = uploadDuration.Nanoseconds()
327+ }
328+ rp.diagMu.Unlock()
322 }329 }
323 }330 }
324 331
@@ -333,12 +340,14 @@ func (rp *renderPipeline) onGLRender() bool {
333 rp.drawPopupOverlay(w, h)340 rp.drawPopupOverlay(w, h)
334 }341 }
335 342
336- rp.glRenderCount++
337 renderDuration := time.Since(renderStartedAt)343 renderDuration := time.Since(renderStartedAt)
344+ rp.diagMu.Lock()
345+ rp.glRenderCount++
338 rp.renderTotalNs += uint64(renderDuration)346 rp.renderTotalNs += uint64(renderDuration)
339 if renderDuration.Nanoseconds() > rp.maxRenderNs {347 if renderDuration.Nanoseconds() > rp.maxRenderNs {
340 rp.maxRenderNs = renderDuration.Nanoseconds()348 rp.maxRenderNs = renderDuration.Nanoseconds()
341 }349 }
350+ rp.diagMu.Unlock()
342 rp.maybeLogDiagnostics()351 rp.maybeLogDiagnostics()
343 if rp.ctx != nil {352 if rp.ctx != nil {
344 logging.FromContext(rp.ctx).Trace().353 logging.FromContext(rp.ctx).Trace().
@@ -368,19 +377,27 @@ func (rp *renderPipeline) drawTexture(texture uint32) {
368 }377 }
369 378
370 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then379 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
371-// transfers to the GL texture via async DMA.380+// transfers to the GL texture via async DMA. The mutex serializes CEF-thread
372-func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {381+// staging writes against GTK-thread PBO copies so the upload sees a consistent
382+// staging buffer and dirty-rect set.
383+func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, bool) {
373 gl := rp.gl384 gl := rp.gl
374 385
375- w, h, _ := rp.viewRectSize()386+ rp.mu.Lock()
387+ w := rp.glWidth
388+ h := rp.glHeight
389+ needsUpload := rp.needsUpload
390+ rp.mu.Unlock()
391+ if !needsUpload || w <= 0 || h <= 0 {
392+ return 0, false, false
393+ }
394+
376 if rp.ctx != nil {395 if rp.ctx != nil {
377 logging.FromContext(rp.ctx).Trace().396 logging.FromContext(rp.ctx).Trace().
378 Uint64("render_seq", renderSeq).397 Uint64("render_seq", renderSeq).
379 Uint64("paint_seq", paintSeq).398 Uint64("paint_seq", paintSeq).
380 Int32("width", w).399 Int32("width", w).
381 Int32("height", h).400 Int32("height", h).
382- Int("dirty_rect_count", len(dirtyRects)).
383- Bool("full_upload", fullUpload).
384 Msg("cef: uploadToPBO begin")401 Msg("cef: uploadToPBO begin")
385 }402 }
386 403
@@ -395,13 +412,42 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
395 mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)412 mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)
396 if mapped == nil {413 if mapped == nil {
397 gl.bindBuffer(glPixelUnpackBuffer, 0)414 gl.bindBuffer(glPixelUnpackBuffer, 0)
398- return 0415+ return 0, false, false
399 }416 }
400 417
401 mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize))418 mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize))
402 uploadedBytes := uint64(0)419 uploadedBytes := uint64(0)
420+ fullUpload := false
421+ dirtyRects := make([]rect, 0)
403 422
404 rp.mu.Lock()423 rp.mu.Lock()
424+ if !rp.needsUpload {
425+ rp.mu.Unlock()
426+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
427+ gl.bindBuffer(glPixelUnpackBuffer, 0)
428+ return 0, false, false
429+ }
430+ gl.bindBuffer(glPixelUnpackBuffer, 0)
431+ return 0, false, false
432+ }
433+
434+ if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight {
435+ rp.mu.Unlock()
436+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
437+ gl.bindBuffer(glPixelUnpackBuffer, 0)
438+ return 0, false, false
439+ }
440+ gl.bindBuffer(glPixelUnpackBuffer, 0)
441+ if rp.ctx != nil {
442+ logging.FromContext(rp.ctx).Trace().
443+ Uint64("render_seq", renderSeq).
444+ Uint64("paint_seq", paintSeq).
445+ Msg("cef: skipping upload while newer surface resize is pending")
446+ }
447+ return 0, false, false
448+ }
449+
450+ fullUpload = rp.forceFullUpload
405 if fullUpload {451 if fullUpload {
406 copied := copy(mappedSlice, rp.staging)452 copied := copy(mappedSlice, rp.staging)
407 if copied < len(mappedSlice) {453 if copied < len(mappedSlice) {
@@ -409,19 +455,12 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
409 }455 }
410 uploadedBytes = uint64(copied)456 uploadedBytes = uint64(copied)
411 } else {457 } else {
412- for _, rawRect := range dirtyRects {458+ dirtyRects = append(dirtyRects, rp.dirtyRects...)
413- r, ok := clampDirtyRect(rawRect, w, h)459+ uploadedBytes = copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects)
414- if !ok {
415- continue
416- }
417- for row := r.Y; row < r.Y+r.Height; row++ {
418- off := int(row)*stride + int(r.X)*4
419- rowBytes := int(r.Width) * 4
420- copy(mappedSlice[off:off+rowBytes], rp.staging[off:off+rowBytes])
421- uploadedBytes += uint64(rowBytes)
422- }
423- }
424 }460 }
461+ rp.needsUpload = false
462+ rp.forceFullUpload = false
463+ rp.dirtyRects = rp.dirtyRects[:0]
425 rp.mu.Unlock()464 rp.mu.Unlock()
426 465
427 if !gl.unmapBuffer(glPixelUnpackBuffer) {466 if !gl.unmapBuffer(glPixelUnpackBuffer) {
@@ -433,7 +472,7 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
433 Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")472 Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
434 }473 }
435 gl.bindBuffer(glPixelUnpackBuffer, 0)474 gl.bindBuffer(glPixelUnpackBuffer, 0)
436- return 0475+ return 0, fullUpload, false
437 }476 }
438 477
439 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.478 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.
@@ -467,10 +506,12 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
467 Uint64("render_seq", renderSeq).506 Uint64("render_seq", renderSeq).
468 Uint64("paint_seq", paintSeq).507 Uint64("paint_seq", paintSeq).
469 Uint64("uploaded_bytes", uploadedBytes).508 Uint64("uploaded_bytes", uploadedBytes).
509+ Int("dirty_rect_count", len(dirtyRects)).
510+ Bool("full_upload", fullUpload).
470 Int("next_pbo_index", rp.pboIndex).511 Int("next_pbo_index", rp.pboIndex).
471 Msg("cef: uploadToPBO end")512 Msg("cef: uploadToPBO end")
472 }513 }
473- return uploadedBytes514+ return uploadedBytes, fullUpload, true
474 }515 }
475 516
476 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {517 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
@@ -577,15 +618,9 @@ func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
577 rp.gl.viewport(0, 0, viewWidth, viewHeight)618 rp.gl.viewport(0, 0, viewWidth, viewHeight)
578 }619 }
579 620
580-// initGL creates or recreates all GL resources for the current surface size.621+// initGL creates or recreates all GL resources for the provided surface size.
581-func (rp *renderPipeline) initGL() {622+func (rp *renderPipeline) initGL(width, height int32) {
582- rp.mu.Lock()623+ if width <= 0 || height <= 0 {
583- w := rp.width
584- h := rp.height
585- rp.sizeChanged = false
586- rp.mu.Unlock()
587-
588- if w <= 0 || h <= 0 {
589 return624 return
590 }625 }
591 626
@@ -596,6 +631,15 @@ func (rp *renderPipeline) initGL() {
596 rp.deleteGLResources()631 rp.deleteGLResources()
597 }632 }
598 633
634+ rp.mu.Lock()
635+ if rp.widthAtomic.Load() == width && rp.heightAtomic.Load() == height {
636+ rp.sizeChanged = false
637+ }
638+ rp.forceFullUpload = true
639+ rp.glWidth = width
640+ rp.glHeight = height
641+ rp.mu.Unlock()
642+
599 // Texture.643 // Texture.
600 gl.genTextures(1, &rp.texture)644 gl.genTextures(1, &rp.texture)
601 gl.bindTexture(glTexture2D, rp.texture)645 gl.bindTexture(glTexture2D, rp.texture)
@@ -603,11 +647,11 @@ func (rp *renderPipeline) initGL() {
603 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))647 gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
604 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))648 gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
605 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))649 gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
606- gl.texImage2D(glTexture2D, 0, int32(glRGBA), w, h, 0, glBGRA, glUnsignedByte, nil)650+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), width, height, 0, glBGRA, glUnsignedByte, nil)
607 gl.bindTexture(glTexture2D, 0)651 gl.bindTexture(glTexture2D, 0)
608 652
609 // PBOs.653 // PBOs.
610- bufSize := int64(w) * int64(h) * 4654+ bufSize := int64(width) * int64(height) * 4
611 gl.genBuffers(1, &rp.pbo[0])655 gl.genBuffers(1, &rp.pbo[0])
612 gl.genBuffers(1, &rp.pbo[1])656 gl.genBuffers(1, &rp.pbo[1])
613 for i := 0; i < 2; i++ {657 for i := 0; i < 2; i++ {
@@ -650,12 +694,18 @@ func (rp *renderPipeline) initGL() {
650 }694 }
651 695
652 func (rp *renderPipeline) recordAcceleratedPaint() uint64 {696 func (rp *renderPipeline) recordAcceleratedPaint() uint64 {
697+ rp.diagMu.Lock()
653 rp.acceleratedPaintCount++698 rp.acceleratedPaintCount++
699+ count := rp.acceleratedPaintCount
700+ rp.diagMu.Unlock()
654 rp.maybeLogDiagnostics()701 rp.maybeLogDiagnostics()
655- return rp.acceleratedPaintCount702+ return count
656 }703 }
657 704
658 func (rp *renderPipeline) maybeLogDiagnostics() {705 func (rp *renderPipeline) maybeLogDiagnostics() {
706+ rp.diagMu.Lock()
707+ defer rp.diagMu.Unlock()
708+
659 now := time.Now()709 now := time.Now()
660 if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second {710 if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second {
661 return711 return
@@ -777,10 +827,9 @@ func (rp *renderPipeline) onResize(width, height int32) {
777 return v * rp.scale827 return v * rp.scale
778 }828 }
779 829
780- rp.width = scaled(width)830+ w, h := scaled(width), scaled(height)
781- rp.height = scaled(height)831+ rp.widthAtomic.Store(w)
782- rp.widthAtomic.Store(rp.width)832+ rp.heightAtomic.Store(h)
783- rp.heightAtomic.Store(rp.height)
784 rp.sizeChanged = true833 rp.sizeChanged = true
785 834
786 firstCB := rp.onFirstResize835 firstCB := rp.onFirstResize
@@ -788,8 +837,6 @@ func (rp *renderPipeline) onResize(width, height int32) {
788 rp.onFirstResize = nil837 rp.onFirstResize = nil
789 }838 }
790 resizeCB := rp.onResizeCB839 resizeCB := rp.onResizeCB
791-
792- w, h := rp.width, rp.height
793 rp.mu.Unlock()840 rp.mu.Unlock()
794 841
795 if firstCB != nil {842 if firstCB != nil {
@@ -866,12 +913,6 @@ func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, p
866 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {913 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
867 width = rp.widthAtomic.Load()914 width = rp.widthAtomic.Load()
868 height = rp.heightAtomic.Load()915 height = rp.heightAtomic.Load()
869- if width <= 0 || height <= 0 {
870- rp.mu.Lock()
871- width = rp.width
872- height = rp.height
873- rp.mu.Unlock()
874- }
875 scale = rp.scale916 scale = rp.scale
876 if scale <= 0 {917 if scale <= 0 {
877 scale = 1918 scale = 1
@@ -953,6 +994,8 @@ func (rp *renderPipeline) deleteGLResources() {
953 // glDeleteProgram to glLoader if needed.994 // glDeleteProgram to glLoader if needed.
954 rp.program = 0995 rp.program = 0
955 }996 }
997+ rp.glWidth = 0
998+ rp.glHeight = 0
956 }999 }
957 1000
958 func bgraBufferSize(width, height int32) (int, bool) {1001 func bgraBufferSize(width, height int32) (int, bool) {
@@ -966,6 +1009,88 @@ func bgraBufferSize(width, height int32) (int, bool) {
966 return int(size64), true1009 return int(size64), true
967 }1010 }
968 1011
1012+// coalesceDirtyRects merges overlapping or edge-adjacent dirty rects to reduce
1013+// redundant row-by-row PBO copies. If the total dirty area exceeds 60% of the
1014+// surface, it collapses to a single full-surface rect.
1015+//
1016+// This is called inside handlePaint's mu.Lock() section so it must be fast for
1017+// small n. O(n^2) is fine since n is typically < 20.
1018+func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect {
1019+ if len(rects) <= 1 {
1020+ return rects
1021+ }
1022+
1023+ // Check if total dirty area exceeds 60% of the surface — if so,
1024+ // a single full-surface upload is cheaper than per-rect copies.
1025+ totalArea := int64(0)
1026+ surfaceArea := int64(surfaceW) * int64(surfaceH)
1027+ for _, r := range rects {
1028+ totalArea += int64(r.Width) * int64(r.Height)
1029+ }
1030+ if surfaceArea > 0 && totalArea*100 > surfaceArea*60 {
1031+ return []rect{{0, 0, surfaceW, surfaceH}}
1032+ }
1033+
1034+ // Merge overlapping or edge-adjacent rects. Two rects touch if their
1035+ // X ranges overlap-or-touch AND Y ranges overlap-or-touch. When merged,
1036+ // both are replaced with their bounding box. Repeat until stable.
1037+ merged := make([]rect, len(rects))
1038+ copy(merged, rects)
1039+
1040+ changed := true
1041+ for changed {
1042+ changed = false
1043+ for i := 0; i < len(merged); i++ {
1044+ for j := i + 1; j < len(merged); j++ {
1045+ if rectsTouch(merged[i], merged[j]) {
1046+ merged[i] = boundingBox(merged[i], merged[j])
1047+ // Remove j by swapping with last element.
1048+ merged[j] = merged[len(merged)-1]
1049+ merged = merged[:len(merged)-1]
1050+ changed = true
1051+ j-- // re-check the element now at index j
1052+ }
1053+ }
1054+ }
1055+ }
1056+
1057+ return merged
1058+}
1059+
1060+// rectsTouch returns true if two rects overlap or share an edge (adjacent).
1061+func rectsTouch(a, b rect) bool {
1062+ // a's right edge <= b's left edge → no horizontal contact
1063+ if a.X+a.Width < b.X || b.X+b.Width < a.X {
1064+ return false
1065+ }
1066+ // a's bottom edge <= b's top edge → no vertical contact
1067+ if a.Y+a.Height < b.Y || b.Y+b.Height < a.Y {
1068+ return false
1069+ }
1070+ return true
1071+}
1072+
1073+// boundingBox returns the smallest rect containing both a and b.
1074+func boundingBox(a, b rect) rect {
1075+ x := a.X
1076+ if b.X < x {
1077+ x = b.X
1078+ }
1079+ y := a.Y
1080+ if b.Y < y {
1081+ y = b.Y
1082+ }
1083+ right := a.X + a.Width
1084+ if br := b.X + b.Width; br > right {
1085+ right = br
1086+ }
1087+ bottom := a.Y + a.Height
1088+ if bb := b.Y + b.Height; bb > bottom {
1089+ bottom = bb
1090+ }
1091+ return rect{X: x, Y: y, Width: right - x, Height: bottom - y}
1092+}
1093+
969 func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {1094 func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
970 return staging == nil ||1095 return staging == nil ||
971 len(staging) != expectedBytes ||1096 len(staging) != expectedBytes ||
@@ -1038,6 +1163,68 @@ func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rec
1038 return copiedBytes, sanitized, truncated1163 return copiedBytes, sanitized, truncated
1039 }1164 }
1040 1165
1166+// copyDirtyRectsRaw copies only the rows covered by dirty rects from src into
1167+// dst at matching offsets. Both src and dst must be BGRA buffers of dimensions
1168+// width x height. Unlike copyDirtyRectsIntoStaging, the destination buffer's
1169+// existing data outside dirty rect rows is left untouched — the caller must
1170+// ensure that only the copied rows will be read downstream.
1171+//
1172+// On a full-surface dirty rect (e.g. resize), this degrades to copying all
1173+// rows — equivalent to a full buffer copy.
1174+//
1175+// Returns the total bytes copied.
1176+func copyDirtyRectsRaw(dst, src []byte, width, height int32, rects []rect) uint64 {
1177+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1178+ return 0
1179+ }
1180+
1181+ stride := int(width) * 4
1182+ copiedBytes := uint64(0)
1183+
1184+ for _, rawRect := range rects {
1185+ r, ok := clampDirtyRect(rawRect, width, height)
1186+ if !ok {
1187+ continue
1188+ }
1189+
1190+ for row := r.Y; row < r.Y+r.Height; row++ {
1191+ dstOff := int(row)*stride + int(r.X)*4
1192+ if dstOff < 0 || dstOff >= len(dst) {
1193+ break
1194+ }
1195+
1196+ rowBytes := int(r.Width) * 4
1197+ dstEnd := dstOff + rowBytes
1198+ if dstEnd > len(dst) {
1199+ dstEnd = len(dst)
1200+ }
1201+
1202+ srcOff := int(row)*stride + int(r.X)*4
1203+ if srcOff < 0 || srcOff >= len(src) {
1204+ continue
1205+ }
1206+
1207+ srcEnd := srcOff + rowBytes
1208+ if srcEnd > len(src) {
1209+ srcEnd = len(src)
1210+ }
1211+
1212+ copyLen := srcEnd - srcOff
1213+ maxDstLen := dstEnd - dstOff
1214+ if copyLen > maxDstLen {
1215+ copyLen = maxDstLen
1216+ }
1217+
1218+ if copyLen > 0 {
1219+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1220+ copiedBytes += uint64(copyLen)
1221+ }
1222+ }
1223+ }
1224+
1225+ return copiedBytes
1226+}
1227+
1041 // pboOffset converts a byte offset into an unsafe.Pointer for use with1228 // pboOffset converts a byte offset into an unsafe.Pointer for use with
1042 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a1229 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
1043 // byte offset into the bound buffer rather than a real memory address.1230 // byte offset into the bound buffer rather than a real memory address.
M internal/infrastructure/cef/render_pipeline_test.go +202 -0
@@ -2,8 +2,11 @@ package cef
22
33 import (
44 "bytes"
5+ "context"
6+ "sort"
57 "testing"
68
9+ "github.com/stretchr/testify/assert"
710 "github.com/stretchr/testify/require"
811 )
912
@@ -39,3 +42,202 @@ func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
3942 require.Equal(t, src, dst[:len(src)])
4043 require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
4144 }
45+
46+func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) {
47+ rp := &renderPipeline{
48+ ctx: context.Background(),
49+ scale: 1,
50+ }
51+
52+ initial := []byte{
53+ 1, 2, 3, 4,
54+ 5, 6, 7, 8,
55+ 9, 10, 11, 12,
56+ 13, 14, 15, 16,
57+ }
58+ rp.handlePaint(initial, 2, 2, []rect{{X: 0, Y: 0, Width: 2, Height: 2}}, 1)
59+
60+ require.Equal(t, initial, rp.staging)
61+ require.True(t, rp.needsUpload)
62+ require.True(t, rp.sizeChanged)
63+ require.True(t, rp.forceFullUpload)
64+
65+ rp.mu.Lock()
66+ rp.dirtyRects = nil
67+ rp.needsUpload = false
68+ rp.sizeChanged = false
69+ rp.forceFullUpload = false
70+ rp.mu.Unlock()
71+
72+ updated := append([]byte(nil), initial...)
73+ copy(updated[8:12], []byte{42, 43, 44, 45})
74+ rp.handlePaint(updated, 2, 2, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, 2)
75+
76+ require.Equal(t, updated, rp.staging)
77+ require.Equal(t, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, rp.dirtyRects)
78+ require.True(t, rp.needsUpload)
79+ require.False(t, rp.sizeChanged)
80+ require.False(t, rp.forceFullUpload)
81+}
82+
83+// sortRects sorts rects by (X, Y) so tests can compare without caring about order.
84+func sortRects(rects []rect) {
85+ sort.Slice(rects, func(i, j int) bool {
86+ if rects[i].X != rects[j].X {
87+ return rects[i].X < rects[j].X
88+ }
89+ return rects[i].Y < rects[j].Y
90+ })
91+}
92+
93+func TestCoalesceDirtyRectsEmpty(t *testing.T) {
94+ result := coalesceDirtyRects(nil, 1920, 1080)
95+ assert.Nil(t, result)
96+
97+ result = coalesceDirtyRects([]rect{}, 1920, 1080)
98+ assert.Empty(t, result)
99+}
100+
101+func TestCoalesceDirtyRectsSingle(t *testing.T) {
102+ input := []rect{{10, 20, 30, 40}}
103+ result := coalesceDirtyRects(input, 1920, 1080)
104+ require.Len(t, result, 1)
105+ assert.Equal(t, rect{10, 20, 30, 40}, result[0])
106+}
107+
108+func TestCoalesceDirtyRectsDisjoint(t *testing.T) {
109+ // Top-left cursor blink and bottom-right scrollbar: should NOT merge.
110+ input := []rect{
111+ {0, 0, 2, 16}, // cursor at top-left
112+ {1900, 1060, 20, 20}, // scrollbar at bottom-right
113+ }
114+ result := coalesceDirtyRects(input, 1920, 1080)
115+ require.Len(t, result, 2)
116+ sortRects(result)
117+ assert.Equal(t, rect{0, 0, 2, 16}, result[0])
118+ assert.Equal(t, rect{1900, 1060, 20, 20}, result[1])
119+}
120+
121+func TestCoalesceDirtyRectsOverlapping(t *testing.T) {
122+ // Two overlapping rects merge to their bounding box.
123+ input := []rect{
124+ {10, 10, 30, 30},
125+ {20, 20, 30, 30},
126+ }
127+ result := coalesceDirtyRects(input, 1920, 1080)
128+ require.Len(t, result, 1)
129+ assert.Equal(t, rect{10, 10, 40, 40}, result[0])
130+}
131+
132+func TestCoalesceDirtyRectsEdgeAdjacent(t *testing.T) {
133+ // Two rects sharing an edge (right edge of A touches left edge of B).
134+ input := []rect{
135+ {0, 0, 50, 100},
136+ {50, 0, 50, 100},
137+ }
138+ result := coalesceDirtyRects(input, 1920, 1080)
139+ require.Len(t, result, 1)
140+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
141+}
142+
143+func TestCoalesceDirtyRectsEdgeAdjacentVertical(t *testing.T) {
144+ // Two rects sharing a vertical edge (bottom of A touches top of B).
145+ input := []rect{
146+ {0, 0, 100, 50},
147+ {0, 50, 100, 50},
148+ }
149+ result := coalesceDirtyRects(input, 1920, 1080)
150+ require.Len(t, result, 1)
151+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
152+}
153+
154+func TestCoalesceDirtyRectsTwoOverlapOneDisjoint(t *testing.T) {
155+ // Three rects: first two overlap, third is disjoint.
156+ input := []rect{
157+ {10, 10, 20, 20},
158+ {20, 10, 20, 20},
159+ {500, 500, 10, 10},
160+ }
161+ result := coalesceDirtyRects(input, 1920, 1080)
162+ require.Len(t, result, 2)
163+ sortRects(result)
164+ assert.Equal(t, rect{10, 10, 30, 20}, result[0])
165+ assert.Equal(t, rect{500, 500, 10, 10}, result[1])
166+}
167+
168+func TestCoalesceDirtyRectsAreaThreshold(t *testing.T) {
169+ // Total area > 60% of surface → collapses to single full-surface rect.
170+ // Surface is 100x100 = 10000 pixels. 60% = 6000.
171+ // Two rects: 4000 + 3000 = 7000 > 6000.
172+ input := []rect{
173+ {0, 0, 100, 40}, // 4000 pixels
174+ {0, 40, 100, 30}, // 3000 pixels
175+ }
176+ result := coalesceDirtyRects(input, 100, 100)
177+ require.Len(t, result, 1)
178+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
179+}
180+
181+func TestCoalesceDirtyRectsAreaThresholdBelowDoesNotCollapse(t *testing.T) {
182+ // Total area <= 60% of surface → no full-surface collapse.
183+ // Surface is 100x100 = 10000. 59% = 5900.
184+ input := []rect{
185+ {0, 0, 100, 29}, // 2900 pixels
186+ {0, 70, 100, 29}, // 2900 pixels → total 5800 < 6000
187+ }
188+ result := coalesceDirtyRects(input, 100, 100)
189+ // These two rects are disjoint (gap from Y=29 to Y=70), so no merge.
190+ require.Len(t, result, 2)
191+}
192+
193+func TestCoalesceDirtyRectsFullyContained(t *testing.T) {
194+ // Rect A fully inside rect B → merges to rect B.
195+ input := []rect{
196+ {10, 10, 5, 5}, // small rect inside
197+ {0, 0, 100, 100}, // large outer rect
198+ }
199+ result := coalesceDirtyRects(input, 1920, 1080)
200+ require.Len(t, result, 1)
201+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
202+}
203+
204+func TestCoalesceDirtyRectsChainMerge(t *testing.T) {
205+ // Three rects in a chain: A overlaps B, B overlaps C, but A and C don't.
206+ // All three should merge into one bounding box after iterative merging.
207+ input := []rect{
208+ {0, 0, 20, 20},
209+ {15, 0, 20, 20},
210+ {30, 0, 20, 20},
211+ }
212+ result := coalesceDirtyRects(input, 1920, 1080)
213+ require.Len(t, result, 1)
214+ assert.Equal(t, rect{0, 0, 50, 20}, result[0])
215+}
216+
217+func TestCoalesceDirtyRectsDoesNotMutateInput(t *testing.T) {
218+ input := []rect{
219+ {10, 10, 30, 30},
220+ {20, 20, 30, 30},
221+ }
222+ inputCopy := make([]rect, len(input))
223+ copy(inputCopy, input)
224+
225+ coalesceDirtyRects(input, 1920, 1080)
226+
227+ // Original input should be unchanged.
228+ assert.Equal(t, inputCopy, input)
229+}
230+
231+func TestRectsTouchSeparatedByGap(t *testing.T) {
232+ // One pixel gap between rects → should NOT touch.
233+ a := rect{0, 0, 10, 10}
234+ b := rect{11, 0, 10, 10}
235+ assert.False(t, rectsTouch(a, b))
236+}
237+
238+func TestRectsTouchExactlyAdjacent(t *testing.T) {
239+ // Right edge of A at X=10, left edge of B at X=10 → touching.
240+ a := rect{0, 0, 10, 10}
241+ b := rect{10, 0, 10, 10}
242+ assert.True(t, rectsTouch(a, b))
243+}
@@ -2,8 +2,11 @@ package cef
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
6+ "sort"
5 "testing"7 "testing"
6 8
9+ "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
8 )11 )
9 12
@@ -39,3 +42,202 @@ func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
39 require.Equal(t, src, dst[:len(src)])42 require.Equal(t, src, dst[:len(src)])
40 require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])43 require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
41 }44 }
45+
46+func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) {
47+ rp := &renderPipeline{
48+ ctx: context.Background(),
49+ scale: 1,
50+ }
51+
52+ initial := []byte{
53+ 1, 2, 3, 4,
54+ 5, 6, 7, 8,
55+ 9, 10, 11, 12,
56+ 13, 14, 15, 16,
57+ }
58+ rp.handlePaint(initial, 2, 2, []rect{{X: 0, Y: 0, Width: 2, Height: 2}}, 1)
59+
60+ require.Equal(t, initial, rp.staging)
61+ require.True(t, rp.needsUpload)
62+ require.True(t, rp.sizeChanged)
63+ require.True(t, rp.forceFullUpload)
64+
65+ rp.mu.Lock()
66+ rp.dirtyRects = nil
67+ rp.needsUpload = false
68+ rp.sizeChanged = false
69+ rp.forceFullUpload = false
70+ rp.mu.Unlock()
71+
72+ updated := append([]byte(nil), initial...)
73+ copy(updated[8:12], []byte{42, 43, 44, 45})
74+ rp.handlePaint(updated, 2, 2, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, 2)
75+
76+ require.Equal(t, updated, rp.staging)
77+ require.Equal(t, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, rp.dirtyRects)
78+ require.True(t, rp.needsUpload)
79+ require.False(t, rp.sizeChanged)
80+ require.False(t, rp.forceFullUpload)
81+}
82+
83+// sortRects sorts rects by (X, Y) so tests can compare without caring about order.
84+func sortRects(rects []rect) {
85+ sort.Slice(rects, func(i, j int) bool {
86+ if rects[i].X != rects[j].X {
87+ return rects[i].X < rects[j].X
88+ }
89+ return rects[i].Y < rects[j].Y
90+ })
91+}
92+
93+func TestCoalesceDirtyRectsEmpty(t *testing.T) {
94+ result := coalesceDirtyRects(nil, 1920, 1080)
95+ assert.Nil(t, result)
96+
97+ result = coalesceDirtyRects([]rect{}, 1920, 1080)
98+ assert.Empty(t, result)
99+}
100+
101+func TestCoalesceDirtyRectsSingle(t *testing.T) {
102+ input := []rect{{10, 20, 30, 40}}
103+ result := coalesceDirtyRects(input, 1920, 1080)
104+ require.Len(t, result, 1)
105+ assert.Equal(t, rect{10, 20, 30, 40}, result[0])
106+}
107+
108+func TestCoalesceDirtyRectsDisjoint(t *testing.T) {
109+ // Top-left cursor blink and bottom-right scrollbar: should NOT merge.
110+ input := []rect{
111+ {0, 0, 2, 16}, // cursor at top-left
112+ {1900, 1060, 20, 20}, // scrollbar at bottom-right
113+ }
114+ result := coalesceDirtyRects(input, 1920, 1080)
115+ require.Len(t, result, 2)
116+ sortRects(result)
117+ assert.Equal(t, rect{0, 0, 2, 16}, result[0])
118+ assert.Equal(t, rect{1900, 1060, 20, 20}, result[1])
119+}
120+
121+func TestCoalesceDirtyRectsOverlapping(t *testing.T) {
122+ // Two overlapping rects merge to their bounding box.
123+ input := []rect{
124+ {10, 10, 30, 30},
125+ {20, 20, 30, 30},
126+ }
127+ result := coalesceDirtyRects(input, 1920, 1080)
128+ require.Len(t, result, 1)
129+ assert.Equal(t, rect{10, 10, 40, 40}, result[0])
130+}
131+
132+func TestCoalesceDirtyRectsEdgeAdjacent(t *testing.T) {
133+ // Two rects sharing an edge (right edge of A touches left edge of B).
134+ input := []rect{
135+ {0, 0, 50, 100},
136+ {50, 0, 50, 100},
137+ }
138+ result := coalesceDirtyRects(input, 1920, 1080)
139+ require.Len(t, result, 1)
140+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
141+}
142+
143+func TestCoalesceDirtyRectsEdgeAdjacentVertical(t *testing.T) {
144+ // Two rects sharing a vertical edge (bottom of A touches top of B).
145+ input := []rect{
146+ {0, 0, 100, 50},
147+ {0, 50, 100, 50},
148+ }
149+ result := coalesceDirtyRects(input, 1920, 1080)
150+ require.Len(t, result, 1)
151+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
152+}
153+
154+func TestCoalesceDirtyRectsTwoOverlapOneDisjoint(t *testing.T) {
155+ // Three rects: first two overlap, third is disjoint.
156+ input := []rect{
157+ {10, 10, 20, 20},
158+ {20, 10, 20, 20},
159+ {500, 500, 10, 10},
160+ }
161+ result := coalesceDirtyRects(input, 1920, 1080)
162+ require.Len(t, result, 2)
163+ sortRects(result)
164+ assert.Equal(t, rect{10, 10, 30, 20}, result[0])
165+ assert.Equal(t, rect{500, 500, 10, 10}, result[1])
166+}
167+
168+func TestCoalesceDirtyRectsAreaThreshold(t *testing.T) {
169+ // Total area > 60% of surface → collapses to single full-surface rect.
170+ // Surface is 100x100 = 10000 pixels. 60% = 6000.
171+ // Two rects: 4000 + 3000 = 7000 > 6000.
172+ input := []rect{
173+ {0, 0, 100, 40}, // 4000 pixels
174+ {0, 40, 100, 30}, // 3000 pixels
175+ }
176+ result := coalesceDirtyRects(input, 100, 100)
177+ require.Len(t, result, 1)
178+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
179+}
180+
181+func TestCoalesceDirtyRectsAreaThresholdBelowDoesNotCollapse(t *testing.T) {
182+ // Total area <= 60% of surface → no full-surface collapse.
183+ // Surface is 100x100 = 10000. 59% = 5900.
184+ input := []rect{
185+ {0, 0, 100, 29}, // 2900 pixels
186+ {0, 70, 100, 29}, // 2900 pixels → total 5800 < 6000
187+ }
188+ result := coalesceDirtyRects(input, 100, 100)
189+ // These two rects are disjoint (gap from Y=29 to Y=70), so no merge.
190+ require.Len(t, result, 2)
191+}
192+
193+func TestCoalesceDirtyRectsFullyContained(t *testing.T) {
194+ // Rect A fully inside rect B → merges to rect B.
195+ input := []rect{
196+ {10, 10, 5, 5}, // small rect inside
197+ {0, 0, 100, 100}, // large outer rect
198+ }
199+ result := coalesceDirtyRects(input, 1920, 1080)
200+ require.Len(t, result, 1)
201+ assert.Equal(t, rect{0, 0, 100, 100}, result[0])
202+}
203+
204+func TestCoalesceDirtyRectsChainMerge(t *testing.T) {
205+ // Three rects in a chain: A overlaps B, B overlaps C, but A and C don't.
206+ // All three should merge into one bounding box after iterative merging.
207+ input := []rect{
208+ {0, 0, 20, 20},
209+ {15, 0, 20, 20},
210+ {30, 0, 20, 20},
211+ }
212+ result := coalesceDirtyRects(input, 1920, 1080)
213+ require.Len(t, result, 1)
214+ assert.Equal(t, rect{0, 0, 50, 20}, result[0])
215+}
216+
217+func TestCoalesceDirtyRectsDoesNotMutateInput(t *testing.T) {
218+ input := []rect{
219+ {10, 10, 30, 30},
220+ {20, 20, 30, 30},
221+ }
222+ inputCopy := make([]rect, len(input))
223+ copy(inputCopy, input)
224+
225+ coalesceDirtyRects(input, 1920, 1080)
226+
227+ // Original input should be unchanged.
228+ assert.Equal(t, inputCopy, input)
229+}
230+
231+func TestRectsTouchSeparatedByGap(t *testing.T) {
232+ // One pixel gap between rects → should NOT touch.
233+ a := rect{0, 0, 10, 10}
234+ b := rect{11, 0, 10, 10}
235+ assert.False(t, rectsTouch(a, b))
236+}
237+
238+func TestRectsTouchExactlyAdjacent(t *testing.T) {
239+ // Right edge of A at X=10, left edge of B at X=10 → touching.
240+ a := rect{0, 0, 10, 10}
241+ b := rect{10, 0, 10, 10}
242+ assert.True(t, rectsTouch(a, b))
243+}
M internal/infrastructure/cef/scheme_handler.go +17 -166
@@ -8,7 +8,6 @@ import (
88 "fmt"
99 "html"
1010 "io/fs"
11- "mime"
1211 "net/http"
1312 "net/url"
1413 "path/filepath"
@@ -22,6 +21,7 @@ import (
2221 "github.com/bnema/dumber/internal/application/port"
2322 "github.com/bnema/dumber/internal/infrastructure/config"
2423 "github.com/bnema/dumber/internal/infrastructure/env"
24+ "github.com/bnema/dumber/internal/infrastructure/webutil"
2525 "github.com/bnema/dumber/internal/logging"
2626 "github.com/rs/zerolog"
2727 )
@@ -35,6 +35,14 @@ const (
3535 indexHTML = "index.html"
3636 )
3737
38+// pageRootFiles maps internal page hosts/paths to their HTML entry points.
39+var pageRootFiles = map[string]string{
40+ homePath: indexHTML,
41+ configPath: "config.html",
42+ webrtcPath: "webrtc.html",
43+ errorPath: "error.html",
44+}
45+
3846 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
3947 // internal https://dumber.invalid origin used by CEF.
4048 type dumbSchemeHandler struct {
@@ -313,14 +321,7 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) {
313321 return "", false
314322 }
315323
316- rootByHost := map[string]string{
317- homePath: indexHTML,
318- configPath: "config.html",
319- webrtcPath: "webrtc.html",
320- errorPath: "error.html",
321- }
322-
323- if root, ok := rootByHost[u.Host]; ok {
324+ if root, ok := pageRootFiles[u.Host]; ok {
324325 path := strings.TrimPrefix(u.Path, "/")
325326 if path == "" {
326327 return root, true
@@ -333,18 +334,10 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) {
333334 }
334335
335336 // Handle opaque URLs like dumb:home.
336- switch u.Opaque {
337- case homePath:
338- return indexHTML, true
339- case configPath:
340- return "config.html", true
341- case webrtcPath:
342- return "webrtc.html", true
343- case errorPath:
344- return "error.html", true
345- default:
346- return "", false
337+ if root, ok := pageRootFiles[u.Opaque]; ok {
338+ return root, true
347339 }
340+ return "", false
348341 }
349342
350343 func resolveActualAssetPath(u *url.URL) (string, bool) {
@@ -357,13 +350,7 @@ func resolveActualAssetPath(u *url.URL) (string, bool) {
357350 return "", false
358351 }
359352
360- rootByPath := map[string]string{
361- homePath: indexHTML,
362- configPath: "config.html",
363- webrtcPath: "webrtc.html",
364- errorPath: "error.html",
365- }
366- if root, ok := rootByPath[path]; ok {
353+ if root, ok := pageRootFiles[path]; ok {
367354 return root, true
368355 }
369356
@@ -398,151 +385,15 @@ func isCEFCrashPageURL(u *url.URL) bool {
398385 }
399386
400387 func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
401- if originalURI == "" {
402- return ""
403- }
404- parsed, err := url.Parse(originalURI)
405- if err != nil {
406- return ""
407- }
408- switch strings.ToLower(parsed.Scheme) {
409- case "http", "https":
410- if parsed.Host == "" {
411- return ""
412- }
413- return parsed.String()
414- case "dumb":
415- if parsed.Host == "" && parsed.Opaque == "" {
416- return ""
417- }
418- return parsed.String()
419- default:
420- return ""
421- }
388+ return webutil.SanitizeCrashPageOriginalURI(originalURI)
422389 }
423390
424391 func buildCEFCrashPageHTML(originalURI string) string {
425- escapedURI := html.EscapeString(originalURI)
426- return fmt.Sprintf(`<!DOCTYPE html>
427-<html lang="en">
428-<head>
429- <meta charset="utf-8">
430- <meta name="viewport" content="width=device-width, initial-scale=1">
431- <title>Renderer crashed</title>
432- <style>
433- :root { color-scheme: dark; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; }
434- body {
435- margin: 0;
436- min-height: 100vh;
437- display: flex;
438- align-items: center;
439- justify-content: center;
440- background: radial-gradient(circle at top, #253447, #101622 55%%);
441- color: #f2f6fa;
442- padding: 24px;
443- }
444- .card {
445- width: min(640px, 100%%);
446- background: rgba(10, 16, 26, 0.86);
447- border: 1px solid rgba(144, 173, 205, 0.35);
448- border-radius: 16px;
449- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
450- padding: 28px;
451- }
452- .url {
453- margin: 16px 0 20px;
454- padding: 12px;
455- border-radius: 10px;
456- background: rgba(26, 38, 56, 0.85);
457- border: 1px solid rgba(139, 167, 194, 0.28);
458- font-family: "IBM Plex Mono", "Fira Code", monospace;
459- overflow-wrap: anywhere;
460- }
461- .actions { display: flex; gap: 12px; flex-wrap: wrap; }
462- button {
463- border: 0;
464- border-radius: 10px;
465- padding: 10px 16px;
466- cursor: pointer;
467- font-size: 0.95rem;
468- font-weight: 600;
469- }
470- .primary { background: #4dd0e1; color: #061018; }
471- .secondary { background: #233346; color: #d6e5f5; }
472- </style>
473-</head>
474-<body>
475- <div class="card">
476- <h1>Renderer process ended</h1>
477- <p>The current page was interrupted. You can reload it to continue browsing.</p>
478- <div class="url">%s</div>
479- <div class="actions">
480- <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
481- <button class="secondary" id="stay-btn">Stay on this page</button>
482- </div>
483- </div>
484- <script>
485- const reloadButton = document.getElementById('reload-btn');
486- const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
487- reloadButton.addEventListener('click', function() {
488- if (targetUrl) {
489- window.location.href = targetUrl;
490- return;
491- }
492- window.location.reload();
493- });
494- document.getElementById('stay-btn').addEventListener('click', function() {
495- this.disabled = true;
496- this.textContent = 'Staying on page';
497- });
498- </script>
499-</body>
500-</html>`, escapedURI, escapedURI)
392+ return webutil.BuildCrashPageHTML(originalURI)
501393 }
502394
503-// getMimeType determines the MIME type for a given file path.
504395 func getMimeType(filename string) string {
505- ext := strings.ToLower(filepath.Ext(filename))
506-
507- mt := mime.TypeByExtension(ext)
508- if mt != "" {
509- return mt
510- }
511-
512- switch ext {
513- case ".js", ".mjs":
514- return "application/javascript"
515- case ".css":
516- return "text/css"
517- case ".svg":
518- return "image/svg+xml"
519- case ".ico":
520- return "image/x-icon"
521- case ".woff":
522- return "font/woff"
523- case ".woff2":
524- return "font/woff2"
525- case ".ttf":
526- return "font/ttf"
527- case ".otf":
528- return "font/otf"
529- case ".png":
530- return "image/png"
531- case ".jpg", ".jpeg":
532- return "image/jpeg"
533- case ".gif":
534- return "image/gif"
535- case ".webp":
536- return "image/webp"
537- case ".json":
538- return "application/json"
539- case ".xml":
540- return "application/xml"
541- case ".html", ".htm":
542- return "text/html; charset=utf-8"
543- default:
544- return "text/plain"
545- }
396+ return webutil.GetMimeType(filename)
546397 }
547398
548399 // splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
@@ -8,7 +8,6 @@ import (
8 "fmt"8 "fmt"
9 "html"9 "html"
10 "io/fs"10 "io/fs"
11- "mime"
12 "net/http"11 "net/http"
13 "net/url"12 "net/url"
14 "path/filepath"13 "path/filepath"
@@ -22,6 +21,7 @@ import (
22 "github.com/bnema/dumber/internal/application/port"21 "github.com/bnema/dumber/internal/application/port"
23 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
24 "github.com/bnema/dumber/internal/infrastructure/env"23 "github.com/bnema/dumber/internal/infrastructure/env"
24+ "github.com/bnema/dumber/internal/infrastructure/webutil"
25 "github.com/bnema/dumber/internal/logging"25 "github.com/bnema/dumber/internal/logging"
26 "github.com/rs/zerolog"26 "github.com/rs/zerolog"
27 )27 )
@@ -35,6 +35,14 @@ const (
35 indexHTML = "index.html"35 indexHTML = "index.html"
36 )36 )
37 37
38+// pageRootFiles maps internal page hosts/paths to their HTML entry points.
39+var pageRootFiles = map[string]string{
40+ homePath: indexHTML,
41+ configPath: "config.html",
42+ webrtcPath: "webrtc.html",
43+ errorPath: "error.html",
44+}
45+
38 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual46 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
39 // internal https://dumber.invalid origin used by CEF.47 // internal https://dumber.invalid origin used by CEF.
40 type dumbSchemeHandler struct {48 type dumbSchemeHandler struct {
@@ -313,14 +321,7 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) {
313 return "", false321 return "", false
314 }322 }
315 323
316- rootByHost := map[string]string{324+ if root, ok := pageRootFiles[u.Host]; ok {
317- homePath: indexHTML,
318- configPath: "config.html",
319- webrtcPath: "webrtc.html",
320- errorPath: "error.html",
321- }
322-
323- if root, ok := rootByHost[u.Host]; ok {
324 path := strings.TrimPrefix(u.Path, "/")325 path := strings.TrimPrefix(u.Path, "/")
325 if path == "" {326 if path == "" {
326 return root, true327 return root, true
@@ -333,18 +334,10 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) {
333 }334 }
334 335
335 // Handle opaque URLs like dumb:home.336 // Handle opaque URLs like dumb:home.
336- switch u.Opaque {337+ if root, ok := pageRootFiles[u.Opaque]; ok {
337- case homePath:338+ return root, true
338- return indexHTML, true
339- case configPath:
340- return "config.html", true
341- case webrtcPath:
342- return "webrtc.html", true
343- case errorPath:
344- return "error.html", true
345- default:
346- return "", false
347 }339 }
340+ return "", false
348 }341 }
349 342
350 func resolveActualAssetPath(u *url.URL) (string, bool) {343 func resolveActualAssetPath(u *url.URL) (string, bool) {
@@ -357,13 +350,7 @@ func resolveActualAssetPath(u *url.URL) (string, bool) {
357 return "", false350 return "", false
358 }351 }
359 352
360- rootByPath := map[string]string{353+ if root, ok := pageRootFiles[path]; ok {
361- homePath: indexHTML,
362- configPath: "config.html",
363- webrtcPath: "webrtc.html",
364- errorPath: "error.html",
365- }
366- if root, ok := rootByPath[path]; ok {
367 return root, true354 return root, true
368 }355 }
369 356
@@ -398,151 +385,15 @@ func isCEFCrashPageURL(u *url.URL) bool {
398 }385 }
399 386
400 func sanitizeCEFCrashPageOriginalURI(originalURI string) string {387 func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
401- if originalURI == "" {388+ return webutil.SanitizeCrashPageOriginalURI(originalURI)
402- return ""
403- }
404- parsed, err := url.Parse(originalURI)
405- if err != nil {
406- return ""
407- }
408- switch strings.ToLower(parsed.Scheme) {
409- case "http", "https":
410- if parsed.Host == "" {
411- return ""
412- }
413- return parsed.String()
414- case "dumb":
415- if parsed.Host == "" && parsed.Opaque == "" {
416- return ""
417- }
418- return parsed.String()
419- default:
420- return ""
421- }
422 }389 }
423 390
424 func buildCEFCrashPageHTML(originalURI string) string {391 func buildCEFCrashPageHTML(originalURI string) string {
425- escapedURI := html.EscapeString(originalURI)392+ return webutil.BuildCrashPageHTML(originalURI)
426- return fmt.Sprintf(`<!DOCTYPE html>
427-<html lang="en">
428-<head>
429- <meta charset="utf-8">
430- <meta name="viewport" content="width=device-width, initial-scale=1">
431- <title>Renderer crashed</title>
432- <style>
433- :root { color-scheme: dark; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; }
434- body {
435- margin: 0;
436- min-height: 100vh;
437- display: flex;
438- align-items: center;
439- justify-content: center;
440- background: radial-gradient(circle at top, #253447, #101622 55%%);
441- color: #f2f6fa;
442- padding: 24px;
443- }
444- .card {
445- width: min(640px, 100%%);
446- background: rgba(10, 16, 26, 0.86);
447- border: 1px solid rgba(144, 173, 205, 0.35);
448- border-radius: 16px;
449- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
450- padding: 28px;
451- }
452- .url {
453- margin: 16px 0 20px;
454- padding: 12px;
455- border-radius: 10px;
456- background: rgba(26, 38, 56, 0.85);
457- border: 1px solid rgba(139, 167, 194, 0.28);
458- font-family: "IBM Plex Mono", "Fira Code", monospace;
459- overflow-wrap: anywhere;
460- }
461- .actions { display: flex; gap: 12px; flex-wrap: wrap; }
462- button {
463- border: 0;
464- border-radius: 10px;
465- padding: 10px 16px;
466- cursor: pointer;
467- font-size: 0.95rem;
468- font-weight: 600;
469- }
470- .primary { background: #4dd0e1; color: #061018; }
471- .secondary { background: #233346; color: #d6e5f5; }
472- </style>
473-</head>
474-<body>
475- <div class="card">
476- <h1>Renderer process ended</h1>
477- <p>The current page was interrupted. You can reload it to continue browsing.</p>
478- <div class="url">%s</div>
479- <div class="actions">
480- <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
481- <button class="secondary" id="stay-btn">Stay on this page</button>
482- </div>
483- </div>
484- <script>
485- const reloadButton = document.getElementById('reload-btn');
486- const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
487- reloadButton.addEventListener('click', function() {
488- if (targetUrl) {
489- window.location.href = targetUrl;
490- return;
491- }
492- window.location.reload();
493- });
494- document.getElementById('stay-btn').addEventListener('click', function() {
495- this.disabled = true;
496- this.textContent = 'Staying on page';
497- });
498- </script>
499-</body>
500-</html>`, escapedURI, escapedURI)
501 }393 }
502 394
503-// getMimeType determines the MIME type for a given file path.
504 func getMimeType(filename string) string {395 func getMimeType(filename string) string {
505- ext := strings.ToLower(filepath.Ext(filename))396+ return webutil.GetMimeType(filename)
506-
507- mt := mime.TypeByExtension(ext)
508- if mt != "" {
509- return mt
510- }
511-
512- switch ext {
513- case ".js", ".mjs":
514- return "application/javascript"
515- case ".css":
516- return "text/css"
517- case ".svg":
518- return "image/svg+xml"
519- case ".ico":
520- return "image/x-icon"
521- case ".woff":
522- return "font/woff"
523- case ".woff2":
524- return "font/woff2"
525- case ".ttf":
526- return "font/ttf"
527- case ".otf":
528- return "font/otf"
529- case ".png":
530- return "image/png"
531- case ".jpg", ".jpeg":
532- return "image/jpeg"
533- case ".gif":
534- return "image/gif"
535- case ".webp":
536- return "image/webp"
537- case ".json":
538- return "application/json"
539- case ".xml":
540- return "application/xml"
541- case ".html", ".htm":
542- return "text/html; charset=utf-8"
543- default:
544- return "text/plain"
545- }
546 }397 }
547 398
548 // splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").399 // splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
M internal/infrastructure/cef/webview.go +5 -9
@@ -102,7 +102,6 @@ func (fn cefTaskFunc) Execute() {
102102 // Identity
103103 // ---------------------------------------------------------------------------
104104
105-// ID returns the unique identifier for this WebView.
106105 func (wv *WebView) ID() port.WebViewID {
107106 return wv.id
108107 }
@@ -111,7 +110,6 @@ func (wv *WebView) ID() port.WebViewID {
111110 // Navigation
112111 // ---------------------------------------------------------------------------
113112
114-// LoadURI navigates to the specified URI.
115113 func (wv *WebView) LoadURI(_ context.Context, uri string) error {
116114 if wv.destroyed.Load() {
117115 return errDestroyed
@@ -156,7 +154,6 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
156154 return nil
157155 }
158156
159-// Reload reloads the current page.
160157 func (wv *WebView) Reload(_ context.Context) error {
161158 if wv.destroyed.Load() {
162159 return errDestroyed
@@ -235,21 +232,18 @@ func (wv *WebView) GoForward(_ context.Context) error {
235232 // State queries (read from cache under RLock)
236233 // ---------------------------------------------------------------------------
237234
238-// URI returns the current URI.
239235 func (wv *WebView) URI() string {
240236 wv.mu.RLock()
241237 defer wv.mu.RUnlock()
242238 return wv.uri
243239 }
244240
245-// Title returns the current page title.
246241 func (wv *WebView) Title() string {
247242 wv.mu.RLock()
248243 defer wv.mu.RUnlock()
249244 return wv.title
250245 }
251246
252-// IsLoading returns true if a page is currently loading.
253247 func (wv *WebView) IsLoading() bool {
254248 wv.mu.RLock()
255249 defer wv.mu.RUnlock()
@@ -461,12 +455,10 @@ func (wv *WebView) ResetBackgroundToDefault() {
461455 // Lifecycle
462456 // ---------------------------------------------------------------------------
463457
464-// IsDestroyed returns true if the WebView has been destroyed.
465458 func (wv *WebView) IsDestroyed() bool {
466459 return wv.destroyed.Load()
467460 }
468461
469-// Destroy releases all resources associated with this WebView.
470462 func (wv *WebView) Destroy() {
471463 if !wv.destroyed.CompareAndSwap(false, true) {
472464 return
@@ -621,6 +613,9 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
621613 }
622614
623615 func (wv *WebView) scheduleStartBeginFrameLoop() {
616+ if !externalBeginFrameEnabled() {
617+ return
618+ }
624619 wv.runOnGTK(func() {
625620 wv.startBeginFrameLoop()
626621 })
@@ -689,7 +684,8 @@ func (wv *WebView) runOnGTK(fn func()) {
689684 if fn == nil {
690685 return
691686 }
692- if wv.engine == nil || !wv.engine.multiThreadedMessageLoop {
687+ // When engine is nil (test/bootstrap), call directly.
688+ if wv.engine == nil {
693689 fn()
694690 return
695691 }
@@ -102,7 +102,6 @@ func (fn cefTaskFunc) Execute() {
102 // Identity102 // Identity
103 // ---------------------------------------------------------------------------103 // ---------------------------------------------------------------------------
104 104
105-// ID returns the unique identifier for this WebView.
106 func (wv *WebView) ID() port.WebViewID {105 func (wv *WebView) ID() port.WebViewID {
107 return wv.id106 return wv.id
108 }107 }
@@ -111,7 +110,6 @@ func (wv *WebView) ID() port.WebViewID {
111 // Navigation110 // Navigation
112 // ---------------------------------------------------------------------------111 // ---------------------------------------------------------------------------
113 112
114-// LoadURI navigates to the specified URI.
115 func (wv *WebView) LoadURI(_ context.Context, uri string) error {113 func (wv *WebView) LoadURI(_ context.Context, uri string) error {
116 if wv.destroyed.Load() {114 if wv.destroyed.Load() {
117 return errDestroyed115 return errDestroyed
@@ -156,7 +154,6 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
156 return nil154 return nil
157 }155 }
158 156
159-// Reload reloads the current page.
160 func (wv *WebView) Reload(_ context.Context) error {157 func (wv *WebView) Reload(_ context.Context) error {
161 if wv.destroyed.Load() {158 if wv.destroyed.Load() {
162 return errDestroyed159 return errDestroyed
@@ -235,21 +232,18 @@ func (wv *WebView) GoForward(_ context.Context) error {
235 // State queries (read from cache under RLock)232 // State queries (read from cache under RLock)
236 // ---------------------------------------------------------------------------233 // ---------------------------------------------------------------------------
237 234
238-// URI returns the current URI.
239 func (wv *WebView) URI() string {235 func (wv *WebView) URI() string {
240 wv.mu.RLock()236 wv.mu.RLock()
241 defer wv.mu.RUnlock()237 defer wv.mu.RUnlock()
242 return wv.uri238 return wv.uri
243 }239 }
244 240
245-// Title returns the current page title.
246 func (wv *WebView) Title() string {241 func (wv *WebView) Title() string {
247 wv.mu.RLock()242 wv.mu.RLock()
248 defer wv.mu.RUnlock()243 defer wv.mu.RUnlock()
249 return wv.title244 return wv.title
250 }245 }
251 246
252-// IsLoading returns true if a page is currently loading.
253 func (wv *WebView) IsLoading() bool {247 func (wv *WebView) IsLoading() bool {
254 wv.mu.RLock()248 wv.mu.RLock()
255 defer wv.mu.RUnlock()249 defer wv.mu.RUnlock()
@@ -461,12 +455,10 @@ func (wv *WebView) ResetBackgroundToDefault() {
461 // Lifecycle455 // Lifecycle
462 // ---------------------------------------------------------------------------456 // ---------------------------------------------------------------------------
463 457
464-// IsDestroyed returns true if the WebView has been destroyed.
465 func (wv *WebView) IsDestroyed() bool {458 func (wv *WebView) IsDestroyed() bool {
466 return wv.destroyed.Load()459 return wv.destroyed.Load()
467 }460 }
468 461
469-// Destroy releases all resources associated with this WebView.
470 func (wv *WebView) Destroy() {462 func (wv *WebView) Destroy() {
471 if !wv.destroyed.CompareAndSwap(false, true) {463 if !wv.destroyed.CompareAndSwap(false, true) {
472 return464 return
@@ -621,6 +613,9 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
621 }613 }
622 614
623 func (wv *WebView) scheduleStartBeginFrameLoop() {615 func (wv *WebView) scheduleStartBeginFrameLoop() {
616+ if !externalBeginFrameEnabled() {
617+ return
618+ }
624 wv.runOnGTK(func() {619 wv.runOnGTK(func() {
625 wv.startBeginFrameLoop()620 wv.startBeginFrameLoop()
626 })621 })
@@ -689,7 +684,8 @@ func (wv *WebView) runOnGTK(fn func()) {
689 if fn == nil {684 if fn == nil {
690 return685 return
691 }686 }
692- if wv.engine == nil || !wv.engine.multiThreadedMessageLoop {687+ // When engine is nil (test/bootstrap), call directly.
688+ if wv.engine == nil {
693 fn()689 fn()
694 return690 return
695 }691 }
M internal/infrastructure/cef/webview_handlers.go +13 -14
@@ -188,8 +188,10 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
188188 }
189189
190190 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
191-// to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we
192-// must first copy the transient CEF buffer before hopping back to GTK.
191+// to the render pipeline for GPU upload. Main-view paints are copied directly
192+// into the persistent staging buffer on the CEF UI thread; only the GTK
193+// QueueRender hop remains. Popups still copy their transient buffer before
194+// crossing threads because they are small and infrequent.
193195 func (h *handlerSet) OnPaint(
194196 _ purecef.Browser, elementType purecef.PaintElementType,
195197 dirtyRects []purecef.Rect, buffer []byte, width, height int32,
@@ -211,32 +213,29 @@ func (h *handlerSet) OnPaint(
211213 for i, dr := range dirtyRects {
212214 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
213215 }
214- if h.wv.engine != nil && h.wv.engine.multiThreadedMessageLoop {
216+
217+ if elementType == purecef.PaintElementTypePetPopup {
215218 pixels := make([]byte, len(buffer))
216219 copy(pixels, buffer)
217220 h.wv.runOnGTK(func() {
218- if elementType == purecef.PaintElementTypePetPopup {
219- h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
220- return
221- }
222- h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
221+ h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
223222 })
224223 if h.wv != nil && h.wv.ctx != nil {
225224 logging.FromContext(h.wv.ctx).Trace().
226225 Uint64("paint_seq", paintSeq).
227- Msg("cef: OnPaint queued to GTK")
226+ Msg("cef: OnPaint queued popup to GTK")
228227 }
229228 return
230229 }
231- if elementType == purecef.PaintElementTypePetPopup {
232- h.wv.pipeline.handlePopupPaint(buffer, width, height, paintSeq)
233- return
234- }
230+
235231 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
232+ h.wv.runOnGTK(func() {
233+ h.wv.pipeline.queuePaintRender()
234+ })
236235 if h.wv != nil && h.wv.ctx != nil {
237236 logging.FromContext(h.wv.ctx).Trace().
238237 Uint64("paint_seq", paintSeq).
239- Msg("cef: OnPaint handled inline")
238+ Msg("cef: OnPaint queued to GTK")
240239 }
241240 }
242241
@@ -188,8 +188,10 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
188 }188 }
189 189
190 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects190 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
191-// to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we191+// to the render pipeline for GPU upload. Main-view paints are copied directly
192-// must first copy the transient CEF buffer before hopping back to GTK.192+// into the persistent staging buffer on the CEF UI thread; only the GTK
193+// QueueRender hop remains. Popups still copy their transient buffer before
194+// crossing threads because they are small and infrequent.
193 func (h *handlerSet) OnPaint(195 func (h *handlerSet) OnPaint(
194 _ purecef.Browser, elementType purecef.PaintElementType,196 _ purecef.Browser, elementType purecef.PaintElementType,
195 dirtyRects []purecef.Rect, buffer []byte, width, height int32,197 dirtyRects []purecef.Rect, buffer []byte, width, height int32,
@@ -211,32 +213,29 @@ func (h *handlerSet) OnPaint(
211 for i, dr := range dirtyRects {213 for i, dr := range dirtyRects {
212 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}214 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
213 }215 }
214- if h.wv.engine != nil && h.wv.engine.multiThreadedMessageLoop {216+
217+ if elementType == purecef.PaintElementTypePetPopup {
215 pixels := make([]byte, len(buffer))218 pixels := make([]byte, len(buffer))
216 copy(pixels, buffer)219 copy(pixels, buffer)
217 h.wv.runOnGTK(func() {220 h.wv.runOnGTK(func() {
218- if elementType == purecef.PaintElementTypePetPopup {221+ h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
219- h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
220- return
221- }
222- h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
223 })222 })
224 if h.wv != nil && h.wv.ctx != nil {223 if h.wv != nil && h.wv.ctx != nil {
225 logging.FromContext(h.wv.ctx).Trace().224 logging.FromContext(h.wv.ctx).Trace().
226 Uint64("paint_seq", paintSeq).225 Uint64("paint_seq", paintSeq).
227- Msg("cef: OnPaint queued to GTK")226+ Msg("cef: OnPaint queued popup to GTK")
228 }227 }
229 return228 return
230 }229 }
231- if elementType == purecef.PaintElementTypePetPopup {230+
232- h.wv.pipeline.handlePopupPaint(buffer, width, height, paintSeq)
233- return
234- }
235 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)231 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
232+ h.wv.runOnGTK(func() {
233+ h.wv.pipeline.queuePaintRender()
234+ })
236 if h.wv != nil && h.wv.ctx != nil {235 if h.wv != nil && h.wv.ctx != nil {
237 logging.FromContext(h.wv.ctx).Trace().236 logging.FromContext(h.wv.ctx).Trace().
238 Uint64("paint_seq", paintSeq).237 Uint64("paint_seq", paintSeq).
239- Msg("cef: OnPaint handled inline")238+ Msg("cef: OnPaint queued to GTK")
240 }239 }
241 }240 }
242 241
M internal/infrastructure/cef/webview_handlers_test.go +11 -12
@@ -10,6 +10,13 @@ import (
1010 "github.com/bnema/dumber/internal/application/port"
1111 )
1212
13+func newTestPipeline(w, h, s int32) *renderPipeline {
14+ rp := &renderPipeline{scale: s}
15+ rp.widthAtomic.Store(w)
16+ rp.heightAtomic.Store(h)
17+ return rp
18+}
19+
1320 type stubFrame struct {
1421 main bool
1522 url string
@@ -65,12 +72,8 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
6572 rect := &purecef.Rect{}
6673 h := &handlerSet{
6774 wv: &WebView{
68- ctx: context.Background(),
69- pipeline: &renderPipeline{
70- width: 800,
71- height: 600,
72- scale: 2,
73- },
75+ ctx: context.Background(),
76+ pipeline: newTestPipeline(800, 600, 2),
7477 },
7578 }
7679
@@ -87,12 +90,8 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
8790 info := &si
8891 h := &handlerSet{
8992 wv: &WebView{
90- ctx: context.Background(),
91- pipeline: &renderPipeline{
92- width: 1500,
93- height: 900,
94- scale: 3,
95- },
93+ ctx: context.Background(),
94+ pipeline: newTestPipeline(1500, 900, 3),
9695 },
9796 }
9897
@@ -10,6 +10,13 @@ import (
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11 )11 )
12 12
13+func newTestPipeline(w, h, s int32) *renderPipeline {
14+ rp := &renderPipeline{scale: s}
15+ rp.widthAtomic.Store(w)
16+ rp.heightAtomic.Store(h)
17+ return rp
18+}
19+
13 type stubFrame struct {20 type stubFrame struct {
14 main bool21 main bool
15 url string22 url string
@@ -65,12 +72,8 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
65 rect := &purecef.Rect{}72 rect := &purecef.Rect{}
66 h := &handlerSet{73 h := &handlerSet{
67 wv: &WebView{74 wv: &WebView{
68- ctx: context.Background(),75+ ctx: context.Background(),
69- pipeline: &renderPipeline{76+ pipeline: newTestPipeline(800, 600, 2),
70- width: 800,
71- height: 600,
72- scale: 2,
73- },
74 },77 },
75 }78 }
76 79
@@ -87,12 +90,8 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
87 info := &si90 info := &si
88 h := &handlerSet{91 h := &handlerSet{
89 wv: &WebView{92 wv: &WebView{
90- ctx: context.Background(),93+ ctx: context.Background(),
91- pipeline: &renderPipeline{94+ pipeline: newTestPipeline(1500, 900, 3),
92- width: 1500,
93- height: 900,
94- scale: 3,
95- },
96 },95 },
97 }96 }
98 97
M internal/infrastructure/config/defaults.go +2 -7
@@ -68,7 +68,6 @@ const (
6868 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
6969 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
7070 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71- defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval
7271
7372 // Transcoding defaults
7473 defaultTranscodingEnabled = true
@@ -92,8 +91,6 @@ func getDefaultLogDir() string {
9291
9392 // DefaultConfig returns the default configuration values for dumber.
9493 func DefaultConfig() *Config {
95- defaultCEFMultiThreadedMessageLoop := true
96-
9794 return &Config{
9895 Database: DatabaseConfig{
9996 // Path is set dynamically in config.Load()
@@ -162,10 +159,8 @@ func DefaultConfig() *Config {
162159 // matches Epiphany's model and avoids a misleading setting.
163160 CookiePolicy: CookiePolicyAlways,
164161 CEF: CEFEngineConfig{
165- WindowlessFrameRate: defaultCEFWindowlessFrameRate,
166- MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop,
167- ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs,
168- LogFile: "",
162+ WindowlessFrameRate: defaultCEFWindowlessFrameRate,
163+ LogFile: "",
169164 },
170165 WebKit: WebKitEngineConfig{
171166 ITPEnabled: true,
@@ -68,7 +68,6 @@ const (
68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71- defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval
72 71
73 // Transcoding defaults72 // Transcoding defaults
74 defaultTranscodingEnabled = true73 defaultTranscodingEnabled = true
@@ -92,8 +91,6 @@ func getDefaultLogDir() string {
92 91
93 // DefaultConfig returns the default configuration values for dumber.92 // DefaultConfig returns the default configuration values for dumber.
94 func DefaultConfig() *Config {93 func DefaultConfig() *Config {
95- defaultCEFMultiThreadedMessageLoop := true
96-
97 return &Config{94 return &Config{
98 Database: DatabaseConfig{95 Database: DatabaseConfig{
99 // Path is set dynamically in config.Load()96 // Path is set dynamically in config.Load()
@@ -162,10 +159,8 @@ func DefaultConfig() *Config {
162 // matches Epiphany's model and avoids a misleading setting.159 // matches Epiphany's model and avoids a misleading setting.
163 CookiePolicy: CookiePolicyAlways,160 CookiePolicy: CookiePolicyAlways,
164 CEF: CEFEngineConfig{161 CEF: CEFEngineConfig{
165- WindowlessFrameRate: defaultCEFWindowlessFrameRate,162+ WindowlessFrameRate: defaultCEFWindowlessFrameRate,
166- MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop,163+ LogFile: "",
167- ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs,
168- LogFile: "",
169 },164 },
170 WebKit: WebKitEngineConfig{165 WebKit: WebKitEngineConfig{
171 ITPEnabled: true,166 ITPEnabled: true,
M internal/infrastructure/config/defaults_test.go +0 -2
@@ -21,8 +21,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2121 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
2222 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
2323 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24- assert.True(t, cfg.Engine.CEF.CEFMultiThreadedMessageLoop())
25- assert.Equal(t, int64(defaultCEFManualPumpIntervalMs), cfg.Engine.CEF.CEFManualPumpIntervalMs())
2624 assert.Empty(t, cfg.Engine.CEF.LogFile)
2725 assert.False(t, cfg.Engine.CEF.EnableAudioHandler)
2826 assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
@@ -21,8 +21,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
21 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)21 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
22 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)22 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24- assert.True(t, cfg.Engine.CEF.CEFMultiThreadedMessageLoop())
25- assert.Equal(t, int64(defaultCEFManualPumpIntervalMs), cfg.Engine.CEF.CEFManualPumpIntervalMs())
26 assert.Empty(t, cfg.Engine.CEF.LogFile)24 assert.Empty(t, cfg.Engine.CEF.LogFile)
27 assert.False(t, cfg.Engine.CEF.EnableAudioHandler)25 assert.False(t, cfg.Engine.CEF.EnableAudioHandler)
28 assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)26 assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
M internal/infrastructure/config/engine.go +10 -26
@@ -2,13 +2,18 @@ package config
22
33 import "os"
44
5+const (
6+ EngineTypeWebKit = "webkit"
7+ EngineTypeCEF = "cef"
8+)
9+
510 // ResolveEngineType returns the effective engine type from config + env override.
611 // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE
712 // to override the configured value for smoke testing.
813 func (e *EngineConfig) ResolveEngineType() string {
914 engineType := e.Type
1015 if engineType == "" {
11- engineType = "webkit"
16+ engineType = EngineTypeWebKit
1217 }
1318 if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
1419 engineType = envEngine
@@ -88,12 +93,6 @@ type CEFEngineConfig struct {
8893 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
8994 // Default: 60. Higher values increase CPU usage.
9095 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
91- // MultiThreadedMessageLoop lets CEF run its own message loop thread.
92- // Default: true. When false, the host drives the pump via a manual timer.
93- MultiThreadedMessageLoop *bool `mapstructure:"multi_threaded_message_loop" toml:"multi_threaded_message_loop" yaml:"multi_threaded_message_loop"` //nolint:lll
94- // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
95- // when MultiThreadedMessageLoop is false. Default: 10.
96- ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
9796 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
9897 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
9998 // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
@@ -102,29 +101,14 @@ type CEFEngineConfig struct {
102101 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
103102 }
104103
105-// CEFMultiThreadedMessageLoop returns the effective value with default true.
106-func (c CEFEngineConfig) CEFMultiThreadedMessageLoop() bool {
107- if c.MultiThreadedMessageLoop != nil {
108- return *c.MultiThreadedMessageLoop
109- }
110- return true
111-}
112-
113-// CEFManualPumpIntervalMs returns the effective value with default 10.
114-func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
115- if c.ManualPumpIntervalMs > 0 {
116- return c.ManualPumpIntervalMs
117- }
118- return defaultCEFManualPumpIntervalMs
119-}
120-
121-// CEFWindowlessFrameRate returns the configured OSR frame rate.
122-// Returns 0 (auto-detect from monitor) when the user hasn't set an explicit value.
104+// CEFWindowlessFrameRate returns the effective OSR frame rate.
105+// A non-positive value falls back to the built-in default to avoid accidentally
106+// matching high-refresh monitors and burning CPU while idling.
123107 func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
124108 if c.WindowlessFrameRate > 0 {
125109 return c.WindowlessFrameRate
126110 }
127- return 0 // auto-detect from monitor refresh rate
111+ return defaultCEFWindowlessFrameRate
128112 }
129113
130114 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
@@ -2,13 +2,18 @@ package config
2 2
3 import "os"3 import "os"
4 4
5+const (
6+ EngineTypeWebKit = "webkit"
7+ EngineTypeCEF = "cef"
8+)
9+
5 // ResolveEngineType returns the effective engine type from config + env override.10 // ResolveEngineType returns the effective engine type from config + env override.
6 // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE11 // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE
7 // to override the configured value for smoke testing.12 // to override the configured value for smoke testing.
8 func (e *EngineConfig) ResolveEngineType() string {13 func (e *EngineConfig) ResolveEngineType() string {
9 engineType := e.Type14 engineType := e.Type
10 if engineType == "" {15 if engineType == "" {
11- engineType = "webkit"16+ engineType = EngineTypeWebKit
12 }17 }
13 if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {18 if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
14 engineType = envEngine19 engineType = envEngine
@@ -88,12 +93,6 @@ type CEFEngineConfig struct {
88 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.93 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
89 // Default: 60. Higher values increase CPU usage.94 // Default: 60. Higher values increase CPU usage.
90 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`95 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
91- // MultiThreadedMessageLoop lets CEF run its own message loop thread.
92- // Default: true. When false, the host drives the pump via a manual timer.
93- MultiThreadedMessageLoop *bool `mapstructure:"multi_threaded_message_loop" toml:"multi_threaded_message_loop" yaml:"multi_threaded_message_loop"` //nolint:lll
94- // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
95- // when MultiThreadedMessageLoop is false. Default: 10.
96- ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
97 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.96 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
98 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`97 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
99 // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.98 // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
@@ -102,29 +101,14 @@ type CEFEngineConfig struct {
102 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`101 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
103 }102 }
104 103
105-// CEFMultiThreadedMessageLoop returns the effective value with default true.104+// CEFWindowlessFrameRate returns the effective OSR frame rate.
106-func (c CEFEngineConfig) CEFMultiThreadedMessageLoop() bool {105+// A non-positive value falls back to the built-in default to avoid accidentally
107- if c.MultiThreadedMessageLoop != nil {106+// matching high-refresh monitors and burning CPU while idling.
108- return *c.MultiThreadedMessageLoop
109- }
110- return true
111-}
112-
113-// CEFManualPumpIntervalMs returns the effective value with default 10.
114-func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
115- if c.ManualPumpIntervalMs > 0 {
116- return c.ManualPumpIntervalMs
117- }
118- return defaultCEFManualPumpIntervalMs
119-}
120-
121-// CEFWindowlessFrameRate returns the configured OSR frame rate.
122-// Returns 0 (auto-detect from monitor) when the user hasn't set an explicit value.
123 func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {107 func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
124 if c.WindowlessFrameRate > 0 {108 if c.WindowlessFrameRate > 0 {
125 return c.WindowlessFrameRate109 return c.WindowlessFrameRate
126 }110 }
127- return 0 // auto-detect from monitor refresh rate111+ return defaultCEFWindowlessFrameRate
128 }112 }
129 113
130 // PerformanceConfigFromEngine constructs a PerformanceConfig from the114 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
A internal/infrastructure/config/engine_test.go +22 -0
@@ -0,0 +1,22 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFWindowlessFrameRateFallsBackToDefault(t *testing.T) {
6+ t.Parallel()
7+
8+ cfg := CEFEngineConfig{}
9+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
10+ t.Fatalf("default frame rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
11+ }
12+
13+ cfg.WindowlessFrameRate = 0
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("zero frame rate fallback = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+
18+ cfg.WindowlessFrameRate = 144
19+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
20+ t.Fatalf("explicit frame rate = %d, want 144", got)
21+ }
22+}
@@ -0,0 +1,22 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFWindowlessFrameRateFallsBackToDefault(t *testing.T) {
6+ t.Parallel()
7+
8+ cfg := CEFEngineConfig{}
9+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
10+ t.Fatalf("default frame rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
11+ }
12+
13+ cfg.WindowlessFrameRate = 0
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("zero frame rate fallback = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+
18+ cfg.WindowlessFrameRate = 144
19+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
20+ t.Fatalf("explicit frame rate = %d, want 144", got)
21+ }
22+}
M internal/infrastructure/config/loader.go +0 -2
@@ -574,8 +574,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
574574 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
575575 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
576576 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
577- m.viper.SetDefault("engine.cef.multi_threaded_message_loop", ce.CEFMultiThreadedMessageLoop())
578- m.viper.SetDefault("engine.cef.manual_pump_interval_ms", ce.CEFManualPumpIntervalMs())
579577 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
580578 m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
581579 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
@@ -574,8 +574,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
574 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)574 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
575 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)575 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
576 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())576 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
577- m.viper.SetDefault("engine.cef.multi_threaded_message_loop", ce.CEFMultiThreadedMessageLoop())
578- m.viper.SetDefault("engine.cef.manual_pump_interval_ms", ce.CEFManualPumpIntervalMs())
579 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)577 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
580 m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)578 m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
581 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)579 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
M internal/infrastructure/config/schema_provider.go +0 -15
@@ -919,21 +919,6 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
919919 Range: ">=0",
920920 Section: SectionPerformance,
921921 },
922- {
923- Key: "engine.cef.multi_threaded_message_loop",
924- Type: "bool",
925- Default: fmt.Sprintf("%t", defaults.Engine.CEF.CEFMultiThreadedMessageLoop()),
926- Description: "Let CEF drive its own UI/message loop thread",
927- Section: SectionPerformance,
928- },
929- {
930- Key: "engine.cef.manual_pump_interval_ms",
931- Type: "int64",
932- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFManualPumpIntervalMs()),
933- Description: "Manual CEF message pump interval when multi-threaded mode is disabled",
934- Range: ">=1",
935- Section: SectionPerformance,
936- },
937922 {
938923 Key: "engine.webkit.skia_cpu_painting_threads",
939924 Type: "int",
@@ -919,21 +919,6 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
919 Range: ">=0",919 Range: ">=0",
920 Section: SectionPerformance,920 Section: SectionPerformance,
921 },921 },
922- {
923- Key: "engine.cef.multi_threaded_message_loop",
924- Type: "bool",
925- Default: fmt.Sprintf("%t", defaults.Engine.CEF.CEFMultiThreadedMessageLoop()),
926- Description: "Let CEF drive its own UI/message loop thread",
927- Section: SectionPerformance,
928- },
929- {
930- Key: "engine.cef.manual_pump_interval_ms",
931- Type: "int64",
932- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFManualPumpIntervalMs()),
933- Description: "Manual CEF message pump interval when multi-threaded mode is disabled",
934- Range: ">=1",
935- Section: SectionPerformance,
936- },
937 {922 {
938 Key: "engine.webkit.skia_cpu_painting_threads",923 Key: "engine.webkit.skia_cpu_painting_threads",
939 Type: "int",924 Type: "int",
M internal/infrastructure/config/validation.go +0 -4
@@ -341,10 +341,6 @@ func validateCEF(config *Config) []string {
341341 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
342342 }
343343
344- if config.Engine.CEF.ManualPumpIntervalMs < 0 {
345- validationErrors = append(validationErrors, "engine.cef.manual_pump_interval_ms must be >= 0")
346- }
347-
348344 return validationErrors
349345 }
350346
@@ -341,10 +341,6 @@ func validateCEF(config *Config) []string {
341 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")341 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
342 }342 }
343 343
344- if config.Engine.CEF.ManualPumpIntervalMs < 0 {
345- validationErrors = append(validationErrors, "engine.cef.manual_pump_interval_ms must be >= 0")
346- }
347-
348 return validationErrors344 return validationErrors
349 }345 }
350 346
M internal/infrastructure/config/validation_test.go +0 -15
@@ -63,13 +63,6 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
6363 },
6464 wantErr: false,
6565 },
66- {
67- name: "zero manual pump interval uses default",
68- mutate: func(cfg *Config) {
69- cfg.Engine.CEF.ManualPumpIntervalMs = 0
70- },
71- wantErr: false,
72- },
7366 {
7467 name: "negative frame rate",
7568 mutate: func(cfg *Config) {
@@ -78,14 +71,6 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
7871 wantErr: true,
7972 wantText: "engine.cef.windowless_frame_rate",
8073 },
81- {
82- name: "negative manual pump interval",
83- mutate: func(cfg *Config) {
84- cfg.Engine.CEF.ManualPumpIntervalMs = -1
85- },
86- wantErr: true,
87- wantText: "engine.cef.manual_pump_interval_ms",
88- },
8974 }
9075
9176 for _, tt := range tests {
@@ -63,13 +63,6 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
63 },63 },
64 wantErr: false,64 wantErr: false,
65 },65 },
66- {
67- name: "zero manual pump interval uses default",
68- mutate: func(cfg *Config) {
69- cfg.Engine.CEF.ManualPumpIntervalMs = 0
70- },
71- wantErr: false,
72- },
73 {66 {
74 name: "negative frame rate",67 name: "negative frame rate",
75 mutate: func(cfg *Config) {68 mutate: func(cfg *Config) {
@@ -78,14 +71,6 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
78 wantErr: true,71 wantErr: true,
79 wantText: "engine.cef.windowless_frame_rate",72 wantText: "engine.cef.windowless_frame_rate",
80 },73 },
81- {
82- name: "negative manual pump interval",
83- mutate: func(cfg *Config) {
84- cfg.Engine.CEF.ManualPumpIntervalMs = -1
85- },
86- wantErr: true,
87- wantText: "engine.cef.manual_pump_interval_ms",
88- },
89 }74 }
90 75
91 for _, tt := range tests {76 for _, tt := range tests {
M internal/infrastructure/handlers/register.go +5 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/infrastructure/handlers/homepage"
11+ "github.com/bnema/dumber/internal/logging"
1112 )
1213
1314 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive
@@ -81,6 +82,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h
8182 }
8283 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
8384 handler.OnKeyPressed(ctx, r, p.Shift)
85+ } else {
86+ logging.FromContext(ctx).Debug().Str("char", p.Char).Msg("ignoring invalid accent_key_press char")
8487 }
8588 return nil, nil
8689 },
@@ -98,6 +101,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h
98101 }
99102 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
100103 handler.OnKeyReleased(ctx, r)
104+ } else {
105+ logging.FromContext(ctx).Debug().Str("char", p.Char).Msg("ignoring invalid accent_key_release char")
101106 }
102107 return nil, nil
103108 },
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/infrastructure/handlers/homepage"10 "github.com/bnema/dumber/internal/infrastructure/handlers/homepage"
11+ "github.com/bnema/dumber/internal/logging"
11 )12 )
12 13
13 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive14 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive
@@ -81,6 +82,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h
81 }82 }
82 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {83 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
83 handler.OnKeyPressed(ctx, r, p.Shift)84 handler.OnKeyPressed(ctx, r, p.Shift)
85+ } else {
86+ logging.FromContext(ctx).Debug().Str("char", p.Char).Msg("ignoring invalid accent_key_press char")
84 }87 }
85 return nil, nil88 return nil, nil
86 },89 },
@@ -98,6 +101,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h
98 }101 }
99 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {102 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
100 handler.OnKeyReleased(ctx, r)103 handler.OnKeyReleased(ctx, r)
104+ } else {
105+ logging.FromContext(ctx).Debug().Str("char", p.Char).Msg("ignoring invalid accent_key_release char")
101 }106 }
102 return nil, nil107 return nil, nil
103 },108 },
M internal/infrastructure/transcoder/audio.go +6 -5
@@ -73,8 +73,9 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
7373 return nil, errors.New("failed to allocate audio encoder context")
7474 }
7575
76- // Opus always works with float planar samples.
77- ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFltp))
76+ // libopus accepts s16 (interleaved) and flt (float interleaved).
77+ // Planar formats (fltp) are NOT supported and cause encoder open to fail.
78+ ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt))
7879 ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)
7980 codecCtxSetBitRate(encCtx, opusBitrate)
8081
@@ -116,7 +117,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
116117
117118 inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)
118119 inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)
119- needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFltp)
120+ needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt)
120121
121122 if needResample {
122123 swr = ffmpeg.SwrAlloc()
@@ -130,7 +131,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
130131 ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)
131132 ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)
132133 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
133- ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFltp)), 0)
134+ ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0)
134135
135136 // Modern FFmpeg (8.x) requires explicit channel layout configuration
136137 // on the SwrContext. Without it, swr_init may succeed but the resampler
@@ -156,7 +157,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
156157 return nil, errors.New("failed to allocate resampled frame")
157158 }
158159
159- frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFltp))
160+ frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt))
160161 frameSetSampleRate(resampledFrame, opusSampleRate)
161162 frameSetNbSamples(resampledFrame, opusFrameSize)
162163 // Copy channel layout from decoder to the resampled frame.
@@ -73,8 +73,9 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
73 return nil, errors.New("failed to allocate audio encoder context")73 return nil, errors.New("failed to allocate audio encoder context")
74 }74 }
75 75
76- // Opus always works with float planar samples.76+ // libopus accepts s16 (interleaved) and flt (float interleaved).
77- ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFltp))77+ // Planar formats (fltp) are NOT supported and cause encoder open to fail.
78+ ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt))
78 ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)79 ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)
79 codecCtxSetBitRate(encCtx, opusBitrate)80 codecCtxSetBitRate(encCtx, opusBitrate)
80 81
@@ -116,7 +117,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
116 117
117 inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)118 inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)
118 inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)119 inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)
119- needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFltp)120+ needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt)
120 121
121 if needResample {122 if needResample {
122 swr = ffmpeg.SwrAlloc()123 swr = ffmpeg.SwrAlloc()
@@ -130,7 +131,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
130 ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)131 ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)
131 ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)132 ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)
132 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)133 ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
133- ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFltp)), 0)134+ ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0)
134 135
135 // Modern FFmpeg (8.x) requires explicit channel layout configuration136 // Modern FFmpeg (8.x) requires explicit channel layout configuration
136 // on the SwrContext. Without it, swr_init may succeed but the resampler137 // on the SwrContext. Without it, swr_init may succeed but the resampler
@@ -156,7 +157,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
156 return nil, errors.New("failed to allocate resampled frame")157 return nil, errors.New("failed to allocate resampled frame")
157 }158 }
158 159
159- frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFltp))160+ frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt))
160 frameSetSampleRate(resampledFrame, opusSampleRate)161 frameSetSampleRate(resampledFrame, opusSampleRate)
161 frameSetNbSamples(resampledFrame, opusFrameSize)162 frameSetNbSamples(resampledFrame, opusFrameSize)
162 // Copy channel layout from decoder to the resampled frame.163 // Copy channel layout from decoder to the resampled frame.
M internal/infrastructure/transcoder/detect.go +4 -2
@@ -34,8 +34,10 @@ var openVideoMIMEs = map[string]bool{
3434 // streamingManifestMIMEs lists manifest/container MIME types that usually
3535 // resolve to proprietary segment streams via HLS or DASH.
3636 var streamingManifestMIMEs = map[string]bool{
37- "application/vnd.apple.mpegurl": true,
38- "application/x-mpegurl": true,
37+ "application/vnd.apple.mpegurl": true, // RFC 8216
38+ "application/x-mpegurl": true, // common non-standard alias
39+ "audio/x-mpegurl": true, // Go stdlib / legacy servers
40+ "audio/mpegurl": true, // rare legacy alias
3941 "application/dash+xml": true,
4042 }
4143
@@ -34,8 +34,10 @@ var openVideoMIMEs = map[string]bool{
34 // streamingManifestMIMEs lists manifest/container MIME types that usually34 // streamingManifestMIMEs lists manifest/container MIME types that usually
35 // resolve to proprietary segment streams via HLS or DASH.35 // resolve to proprietary segment streams via HLS or DASH.
36 var streamingManifestMIMEs = map[string]bool{36 var streamingManifestMIMEs = map[string]bool{
37- "application/vnd.apple.mpegurl": true,37+ "application/vnd.apple.mpegurl": true, // RFC 8216
38- "application/x-mpegurl": true,38+ "application/x-mpegurl": true, // common non-standard alias
39+ "audio/x-mpegurl": true, // Go stdlib / legacy servers
40+ "audio/mpegurl": true, // rare legacy alias
39 "application/dash+xml": true,41 "application/dash+xml": true,
40 }42 }
41 43
M internal/infrastructure/transcoder/detect_test.go +2 -0
@@ -93,6 +93,8 @@ func TestIsStreamingManifestMIME(t *testing.T) {
9393 }{
9494 {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true},
9595 {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true},
96+ {name: "hls audio x-mpegurl", contentType: "audio/x-mpegurl", want: true},
97+ {name: "hls audio mpegurl", contentType: "audio/mpegurl", want: true},
9698 {name: "dash", contentType: "application/dash+xml", want: true},
9799 {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true},
98100 {name: "mp4", contentType: "video/mp4", want: false},
@@ -93,6 +93,8 @@ func TestIsStreamingManifestMIME(t *testing.T) {
93 }{93 }{
94 {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true},94 {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true},
95 {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true},95 {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true},
96+ {name: "hls audio x-mpegurl", contentType: "audio/x-mpegurl", want: true},
97+ {name: "hls audio mpegurl", contentType: "audio/mpegurl", want: true},
96 {name: "dash", contentType: "application/dash+xml", want: true},98 {name: "dash", contentType: "application/dash+xml", want: true},
97 {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true},99 {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true},
98 {name: "mp4", contentType: "video/mp4", want: false},100 {name: "mp4", contentType: "video/mp4", want: false},
M internal/infrastructure/webkit/injector.go +3 -90
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webutil"
1112 "github.com/bnema/dumber/internal/logging"
1213 "github.com/bnema/puregotk-webkit/webkit"
1314 )
@@ -28,91 +29,6 @@ const themeCSSScript = `(function() {
2829 (document.head || document.documentElement).appendChild(style);
2930 })();`
3031
31-// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
32-// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
33-// for JS-based dark mode detection in WebUI components.
34-//
35-// NOTE: This script is NOT injected on external pages. External pages receive
36-// dark mode preference via libadwaita's StyleManager, which WebKit respects
37-// for the native prefers-color-scheme media query.
38-//
39-// The matchMedia patch handles various query formats:
40-// - (prefers-color-scheme: dark)
41-// - (prefers-color-scheme:dark) -- no space
42-// - screen and (prefers-color-scheme: dark)
43-const internalDarkModeScriptTemplate = `(function() {
44- var prefersDark = %t;
45- window.__dumber_gtk_prefers_dark = prefersDark;
46- var originalMatchMedia = window.matchMedia.bind(window);
47-
48- // Apply dark/light class to document element for Tailwind CSS theming
49- if (prefersDark) {
50- document.documentElement.classList.add('dark');
51- document.documentElement.classList.remove('light');
52- } else {
53- document.documentElement.classList.add('light');
54- document.documentElement.classList.remove('dark');
55- }
56-
57- // Helper: Check if query is a prefers-color-scheme query
58- function isColorSchemeQuery(query, scheme) {
59- if (typeof query !== 'string') return false;
60- var normalized = query.replace(/\s+/g, '').toLowerCase();
61- return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
62- }
63-
64- // Create a fake MediaQueryList that implements the full interface
65- function createFakeMediaQueryList(query, matches) {
66- var listeners = [];
67- var onchangeHandler = null;
68-
69- return {
70- matches: matches,
71- media: query,
72- get onchange() { return onchangeHandler; },
73- set onchange(fn) { onchangeHandler = fn; },
74- addListener: function(cb) {
75- if (typeof cb === 'function') listeners.push(cb);
76- },
77- removeListener: function(cb) {
78- var idx = listeners.indexOf(cb);
79- if (idx !== -1) listeners.splice(idx, 1);
80- },
81- addEventListener: function(type, cb) {
82- if (type === 'change' && typeof cb === 'function') {
83- listeners.push(cb);
84- }
85- },
86- removeEventListener: function(type, cb) {
87- if (type === 'change') {
88- var idx = listeners.indexOf(cb);
89- if (idx !== -1) listeners.splice(idx, 1);
90- }
91- },
92- dispatchEvent: function(event) {
93- for (var i = 0; i < listeners.length; i++) {
94- try { listeners[i](event); } catch (e) {}
95- }
96- if (onchangeHandler) {
97- try { onchangeHandler(event); } catch (e) {}
98- }
99- return true;
100- }
101- };
102- }
103-
104- // Patch matchMedia for prefers-color-scheme queries
105- window.matchMedia = function(query) {
106- if (isColorSchemeQuery(query, 'dark')) {
107- return createFakeMediaQueryList(query, prefersDark);
108- }
109- if (isColorSchemeQuery(query, 'light')) {
110- return createFakeMediaQueryList(query, !prefersDark);
111- }
112- return originalMatchMedia(query);
113- };
114-})();`
115-
11632 // internalPageAllowList restricts script injection to internal dumb:// pages only.
11733 var internalPageAllowList = []string{"dumb://*"}
11834
@@ -333,7 +249,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
333249
334250 // 3. Inject dark mode handler for internal pages only
335251 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
336- internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
252+ internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
337253 addScript(
338254 webkit.NewUserScript(
339255 internalDarkModeScript,
@@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
347263
348264 // 4. Inject theme CSS for internal pages (dumb://* only)
349265 if ci.themeCSSVars != "" {
350- // Escape for JS string literal
351- escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
352- escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
353- escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
266+ escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
354267 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
355268 addScript(
356269 webkit.NewUserScript(
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webutil"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
13 )14 )
@@ -28,91 +29,6 @@ const themeCSSScript = `(function() {
28 (document.head || document.documentElement).appendChild(style);29 (document.head || document.documentElement).appendChild(style);
29 })();`30 })();`
30 31
31-// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
32-// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
33-// for JS-based dark mode detection in WebUI components.
34-//
35-// NOTE: This script is NOT injected on external pages. External pages receive
36-// dark mode preference via libadwaita's StyleManager, which WebKit respects
37-// for the native prefers-color-scheme media query.
38-//
39-// The matchMedia patch handles various query formats:
40-// - (prefers-color-scheme: dark)
41-// - (prefers-color-scheme:dark) -- no space
42-// - screen and (prefers-color-scheme: dark)
43-const internalDarkModeScriptTemplate = `(function() {
44- var prefersDark = %t;
45- window.__dumber_gtk_prefers_dark = prefersDark;
46- var originalMatchMedia = window.matchMedia.bind(window);
47-
48- // Apply dark/light class to document element for Tailwind CSS theming
49- if (prefersDark) {
50- document.documentElement.classList.add('dark');
51- document.documentElement.classList.remove('light');
52- } else {
53- document.documentElement.classList.add('light');
54- document.documentElement.classList.remove('dark');
55- }
56-
57- // Helper: Check if query is a prefers-color-scheme query
58- function isColorSchemeQuery(query, scheme) {
59- if (typeof query !== 'string') return false;
60- var normalized = query.replace(/\s+/g, '').toLowerCase();
61- return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
62- }
63-
64- // Create a fake MediaQueryList that implements the full interface
65- function createFakeMediaQueryList(query, matches) {
66- var listeners = [];
67- var onchangeHandler = null;
68-
69- return {
70- matches: matches,
71- media: query,
72- get onchange() { return onchangeHandler; },
73- set onchange(fn) { onchangeHandler = fn; },
74- addListener: function(cb) {
75- if (typeof cb === 'function') listeners.push(cb);
76- },
77- removeListener: function(cb) {
78- var idx = listeners.indexOf(cb);
79- if (idx !== -1) listeners.splice(idx, 1);
80- },
81- addEventListener: function(type, cb) {
82- if (type === 'change' && typeof cb === 'function') {
83- listeners.push(cb);
84- }
85- },
86- removeEventListener: function(type, cb) {
87- if (type === 'change') {
88- var idx = listeners.indexOf(cb);
89- if (idx !== -1) listeners.splice(idx, 1);
90- }
91- },
92- dispatchEvent: function(event) {
93- for (var i = 0; i < listeners.length; i++) {
94- try { listeners[i](event); } catch (e) {}
95- }
96- if (onchangeHandler) {
97- try { onchangeHandler(event); } catch (e) {}
98- }
99- return true;
100- }
101- };
102- }
103-
104- // Patch matchMedia for prefers-color-scheme queries
105- window.matchMedia = function(query) {
106- if (isColorSchemeQuery(query, 'dark')) {
107- return createFakeMediaQueryList(query, prefersDark);
108- }
109- if (isColorSchemeQuery(query, 'light')) {
110- return createFakeMediaQueryList(query, !prefersDark);
111- }
112- return originalMatchMedia(query);
113- };
114-})();`
115-
116 // internalPageAllowList restricts script injection to internal dumb:// pages only.32 // internalPageAllowList restricts script injection to internal dumb:// pages only.
117 var internalPageAllowList = []string{"dumb://*"}33 var internalPageAllowList = []string{"dumb://*"}
118 34
@@ -333,7 +249,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
333 249
334 // 3. Inject dark mode handler for internal pages only250 // 3. Inject dark mode handler for internal pages only
335 // This sets .dark/.light class on <html> and patches matchMedia for WebUI251 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
336- internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)252+ internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
337 addScript(253 addScript(
338 webkit.NewUserScript(254 webkit.NewUserScript(
339 internalDarkModeScript,255 internalDarkModeScript,
@@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
347 263
348 // 4. Inject theme CSS for internal pages (dumb://* only)264 // 4. Inject theme CSS for internal pages (dumb://* only)
349 if ci.themeCSSVars != "" {265 if ci.themeCSSVars != "" {
350- // Escape for JS string literal266+ escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
351- escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
352- escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
353- escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
354 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)267 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
355 addScript(268 addScript(
356 webkit.NewUserScript(269 webkit.NewUserScript(
M internal/infrastructure/webkit/messaging.go +2 -7
@@ -13,13 +13,8 @@ import (
1313 "github.com/bnema/puregotk-webkit/webkit"
1414 )
1515
16-// Message represents a JS -> Go message envelope sent via postMessage.
17-type Message struct {
18- Type string `json:"type"`
19- Payload json.RawMessage `json:"payload"`
20- WebViewID uint64 `json:"webview_id,omitempty"`
21- WebViewIDAlt uint64 `json:"webviewId,omitempty"`
22-}
16+// Message is the JS -> Go message envelope sent via postMessage.
17+type Message = port.WebUIMessage
2318
2419 // MessageHandler is an alias for port.WebUIMessageHandler.
2520 // Kept for backward compatibility within the webkit package.
@@ -13,13 +13,8 @@ import (
13 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
14 )14 )
15 15
16-// Message represents a JS -> Go message envelope sent via postMessage.16+// Message is the JS -> Go message envelope sent via postMessage.
17-type Message struct {17+type Message = port.WebUIMessage
18- Type string `json:"type"`
19- Payload json.RawMessage `json:"payload"`
20- WebViewID uint64 `json:"webview_id,omitempty"`
21- WebViewIDAlt uint64 `json:"webviewId,omitempty"`
22-}
23 18
24 // MessageHandler is an alias for port.WebUIMessageHandler.19 // MessageHandler is an alias for port.WebUIMessageHandler.
25 // Kept for backward compatibility within the webkit package.20 // Kept for backward compatibility within the webkit package.
A internal/infrastructure/webutil/dark_mode.go +58 -0
@@ -0,0 +1,58 @@
1+package webutil
2+
3+import "fmt"
4+
5+const darkModeScriptTemplate = `(function() {
6+ var prefersDark = %t;
7+ window.%s = prefersDark;
8+ var originalMatchMedia = window.matchMedia.bind(window);
9+
10+ if (prefersDark) {
11+ document.documentElement.classList.add('dark');
12+ document.documentElement.classList.remove('light');
13+ } else {
14+ document.documentElement.classList.add('light');
15+ document.documentElement.classList.remove('dark');
16+ }
17+
18+ function isColorSchemeQuery(query, scheme) {
19+ if (typeof query !== 'string') return false;
20+ var normalized = query.replace(/\s+/g, '').toLowerCase();
21+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
22+ }
23+
24+ function createFakeMediaQueryList(query, matches) {
25+ var listeners = [];
26+ var onchangeHandler = null;
27+ return {
28+ matches: matches,
29+ media: query,
30+ get onchange() { return onchangeHandler; },
31+ set onchange(fn) { onchangeHandler = fn; },
32+ addListener: function(cb) { if (typeof cb === 'function') listeners.push(cb); },
33+ removeListener: function(cb) { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); },
34+ addEventListener: function(type, cb) { if (type === 'change' && typeof cb === 'function') listeners.push(cb); },
35+ removeEventListener: function(type, cb) {
36+ if (type === 'change') { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); }
37+ },
38+ dispatchEvent: function(event) {
39+ for (var i = 0; i < listeners.length; i++) { try { listeners[i](event); } catch (e) {} }
40+ if (onchangeHandler) { try { onchangeHandler(event); } catch (e) {} }
41+ return true;
42+ }
43+ };
44+ }
45+
46+ window.matchMedia = function(query) {
47+ if (isColorSchemeQuery(query, 'dark')) return createFakeMediaQueryList(query, prefersDark);
48+ if (isColorSchemeQuery(query, 'light')) return createFakeMediaQueryList(query, !prefersDark);
49+ return originalMatchMedia(query);
50+ };
51+})();`
52+
53+// DarkModeScript returns JS that patches matchMedia and sets dark/light
54+// classes on <html>. The globalVar parameter names the window property
55+// that stores the preference (engine-specific for debuggability).
56+func DarkModeScript(prefersDark bool, globalVar string) string {
57+ return fmt.Sprintf(darkModeScriptTemplate, prefersDark, globalVar)
58+}
@@ -0,0 +1,58 @@
1+package webutil
2+
3+import "fmt"
4+
5+const darkModeScriptTemplate = `(function() {
6+ var prefersDark = %t;
7+ window.%s = prefersDark;
8+ var originalMatchMedia = window.matchMedia.bind(window);
9+
10+ if (prefersDark) {
11+ document.documentElement.classList.add('dark');
12+ document.documentElement.classList.remove('light');
13+ } else {
14+ document.documentElement.classList.add('light');
15+ document.documentElement.classList.remove('dark');
16+ }
17+
18+ function isColorSchemeQuery(query, scheme) {
19+ if (typeof query !== 'string') return false;
20+ var normalized = query.replace(/\s+/g, '').toLowerCase();
21+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
22+ }
23+
24+ function createFakeMediaQueryList(query, matches) {
25+ var listeners = [];
26+ var onchangeHandler = null;
27+ return {
28+ matches: matches,
29+ media: query,
30+ get onchange() { return onchangeHandler; },
31+ set onchange(fn) { onchangeHandler = fn; },
32+ addListener: function(cb) { if (typeof cb === 'function') listeners.push(cb); },
33+ removeListener: function(cb) { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); },
34+ addEventListener: function(type, cb) { if (type === 'change' && typeof cb === 'function') listeners.push(cb); },
35+ removeEventListener: function(type, cb) {
36+ if (type === 'change') { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); }
37+ },
38+ dispatchEvent: function(event) {
39+ for (var i = 0; i < listeners.length; i++) { try { listeners[i](event); } catch (e) {} }
40+ if (onchangeHandler) { try { onchangeHandler(event); } catch (e) {} }
41+ return true;
42+ }
43+ };
44+ }
45+
46+ window.matchMedia = function(query) {
47+ if (isColorSchemeQuery(query, 'dark')) return createFakeMediaQueryList(query, prefersDark);
48+ if (isColorSchemeQuery(query, 'light')) return createFakeMediaQueryList(query, !prefersDark);
49+ return originalMatchMedia(query);
50+ };
51+})();`
52+
53+// DarkModeScript returns JS that patches matchMedia and sets dark/light
54+// classes on <html>. The globalVar parameter names the window property
55+// that stores the preference (engine-specific for debuggability).
56+func DarkModeScript(prefersDark bool, globalVar string) string {
57+ return fmt.Sprintf(darkModeScriptTemplate, prefersDark, globalVar)
58+}
A internal/infrastructure/webutil/js.go +14 -0
@@ -0,0 +1,14 @@
1+package webutil
2+
3+import "strings"
4+
5+// EscapeForJSString escapes a string for use inside a JS single-quoted string literal.
6+func EscapeForJSString(s string) string {
7+ s = strings.ReplaceAll(s, "\\", "\\\\")
8+ s = strings.ReplaceAll(s, "'", "\\'")
9+ s = strings.ReplaceAll(s, "\n", "\\n")
10+ s = strings.ReplaceAll(s, "\r", "\\r")
11+ s = strings.ReplaceAll(s, "\u2028", "\\u2028")
12+ s = strings.ReplaceAll(s, "\u2029", "\\u2029")
13+ return s
14+}
@@ -0,0 +1,14 @@
1+package webutil
2+
3+import "strings"
4+
5+// EscapeForJSString escapes a string for use inside a JS single-quoted string literal.
6+func EscapeForJSString(s string) string {
7+ s = strings.ReplaceAll(s, "\\", "\\\\")
8+ s = strings.ReplaceAll(s, "'", "\\'")
9+ s = strings.ReplaceAll(s, "\n", "\\n")
10+ s = strings.ReplaceAll(s, "\r", "\\r")
11+ s = strings.ReplaceAll(s, "\u2028", "\\u2028")
12+ s = strings.ReplaceAll(s, "\u2029", "\\u2029")
13+ return s
14+}
A internal/infrastructure/webutil/mime.go +55 -0
@@ -0,0 +1,55 @@
1+package webutil
2+
3+import (
4+ "mime"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// GetMimeType determines the MIME type for a given file path.
10+// It tries the standard library first, then falls back to common web asset types.
11+func GetMimeType(filename string) string {
12+ ext := strings.ToLower(filepath.Ext(filename))
13+
14+ mt := mime.TypeByExtension(ext)
15+ if mt != "" {
16+ return mt
17+ }
18+
19+ switch ext {
20+ case ".js", ".mjs":
21+ return "application/javascript"
22+ case ".css":
23+ return "text/css"
24+ case ".svg":
25+ return "image/svg+xml"
26+ case ".ico":
27+ return "image/x-icon"
28+ case ".woff":
29+ return "font/woff"
30+ case ".woff2":
31+ return "font/woff2"
32+ case ".ttf":
33+ return "font/ttf"
34+ case ".otf":
35+ return "font/otf"
36+ case ".png":
37+ return "image/png"
38+ case ".jpg", ".jpeg":
39+ return "image/jpeg"
40+ case ".gif":
41+ return "image/gif"
42+ case ".webp":
43+ return "image/webp"
44+ case ".m3u8":
45+ return "application/vnd.apple.mpegurl" // RFC 8216
46+ case ".json":
47+ return "application/json"
48+ case ".xml":
49+ return "application/xml"
50+ case ".html", ".htm":
51+ return "text/html; charset=utf-8"
52+ default:
53+ return "text/plain"
54+ }
55+}
@@ -0,0 +1,55 @@
1+package webutil
2+
3+import (
4+ "mime"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// GetMimeType determines the MIME type for a given file path.
10+// It tries the standard library first, then falls back to common web asset types.
11+func GetMimeType(filename string) string {
12+ ext := strings.ToLower(filepath.Ext(filename))
13+
14+ mt := mime.TypeByExtension(ext)
15+ if mt != "" {
16+ return mt
17+ }
18+
19+ switch ext {
20+ case ".js", ".mjs":
21+ return "application/javascript"
22+ case ".css":
23+ return "text/css"
24+ case ".svg":
25+ return "image/svg+xml"
26+ case ".ico":
27+ return "image/x-icon"
28+ case ".woff":
29+ return "font/woff"
30+ case ".woff2":
31+ return "font/woff2"
32+ case ".ttf":
33+ return "font/ttf"
34+ case ".otf":
35+ return "font/otf"
36+ case ".png":
37+ return "image/png"
38+ case ".jpg", ".jpeg":
39+ return "image/jpeg"
40+ case ".gif":
41+ return "image/gif"
42+ case ".webp":
43+ return "image/webp"
44+ case ".m3u8":
45+ return "application/vnd.apple.mpegurl" // RFC 8216
46+ case ".json":
47+ return "application/json"
48+ case ".xml":
49+ return "application/xml"
50+ case ".html", ".htm":
51+ return "text/html; charset=utf-8"
52+ default:
53+ return "text/plain"
54+ }
55+}
M internal/ui/app.go +1 -5
@@ -204,11 +204,7 @@ func New(deps *Dependencies) (*App, error) {
204204 })
205205 glib.IdleAdd(&cb, 0)
206206 },
207- SaveConfig: deps.HandlerDeps.SaveConfig,
208- KeybindingsGetter: deps.HandlerDeps.KeybindingsGetter,
209- KeybindingSetter: deps.HandlerDeps.KeybindingSetter,
210- KeybindingResetter: deps.HandlerDeps.KeybindingResetter,
211- AllKeybindingsResetter: deps.HandlerDeps.AllKeybindingsResetter,
207+ HandlerDeps: deps.HandlerDeps,
212208 }); err != nil {
213209 return nil, err
214210 }
@@ -204,11 +204,7 @@ func New(deps *Dependencies) (*App, error) {
204 })204 })
205 glib.IdleAdd(&cb, 0)205 glib.IdleAdd(&cb, 0)
206 },206 },
207- SaveConfig: deps.HandlerDeps.SaveConfig,207+ HandlerDeps: deps.HandlerDeps,
208- KeybindingsGetter: deps.HandlerDeps.KeybindingsGetter,
209- KeybindingSetter: deps.HandlerDeps.KeybindingSetter,
210- KeybindingResetter: deps.HandlerDeps.KeybindingResetter,
211- AllKeybindingsResetter: deps.HandlerDeps.AllKeybindingsResetter,
212 }); err != nil {208 }); err != nil {
213 return nil, err209 return nil, err
214 }210 }

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