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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 110 | 110 | } | |
| 111 | 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 | 114 | logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop") | |
| 115 | 115 | ui.EnsureAdwaitaInitialized() | |
| 116 | 116 | 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 { | |||
| 182 | 182 | 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 | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | |
| 186 | 195 | 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 | |
| 197 | 202 | } | |
| 198 | 203 | ||
| 199 | 204 | // 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) error | 182 | 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 HomepageHistory | 196 | + | HistoryUC HomepageHistory |
| 188 | - | FavoritesUC HomepageFavorites | 197 | + | FavoritesUC HomepageFavorites |
| 189 | - | Clipboard Clipboard | 198 | + | Clipboard Clipboard |
| 190 | - | AutoCopyConfig AutoCopyConfig | 199 | + | AutoCopyConfig AutoCopyConfig |
| 191 | - | OnClipboardCopied func(textLen int) | 200 | + | OnClipboardCopied func(textLen int) |
| 192 | - | SaveConfig func(context.Context, WebUIConfig) error | 201 | + | 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) { | |||
| 28 | 28 | cfg := input.Config | |
| 29 | 29 | engineType := cfg.Engine.ResolveEngineType() | |
| 30 | 30 | switch engineType { | |
| 31 | - | case "webkit": | |
| 31 | + | case config.EngineTypeWebKit: | |
| 32 | 32 | opts := port.EngineOptions{ | |
| 33 | 33 | DataDir: input.DataDir, | |
| 34 | 34 | CacheDir: input.CacheDir, | |
| @@ -40,7 +40,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 40 | 40 | input.Ctx, cfg, opts, wkCfg, | |
| 41 | 41 | input.ThemeManager, input.ColorResolver, input.Logger, | |
| 42 | 42 | ) | |
| 43 | - | case "cef": | |
| 43 | + | case config.EngineTypeCEF: | |
| 44 | 44 | cefCfg := cfg.Engine.CEF | |
| 45 | 45 | return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding) | |
| 46 | 46 | default: | |
| @@ -28,7 +28,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 28 | cfg := input.Config | 28 | 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.CEF | 44 | 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) { | |||
| 22 | 22 | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 23 | 23 | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 24 | 24 | require.Error(t, err) | |
| 25 | - | require.Contains(t, err.Error(), "cef: open library") | |
| 25 | + | require.Contains(t, err.Error(), "cef.InitWithApp") | |
| 26 | 26 | } | |
| @@ -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) { | |||
| 38 | 38 | commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required") | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 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. | |
| 43 | 43 | type dumberApp struct { | |
| 44 | 44 | engine *Engine | |
| 45 | 45 | bph purecef.BrowserProcessHandler | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 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. | |
| 50 | 51 | func newDumberApp(engine *Engine) purecef.App { | |
| 51 | 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 | 53 | app.bph = &dumberBPH{engine: engine} | |
| 55 | - | return purecef.NewApp(app) | |
| 54 | + | return app | |
| 56 | 55 | } | |
| 57 | 56 | ||
| 58 | 57 | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | |
| @@ -102,8 +101,8 @@ func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler | |||
| 102 | 101 | func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil } | |
| 103 | 102 | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | |
| 104 | 103 | ||
| 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. | |
| 107 | 106 | type dumberBPH struct { | |
| 108 | 107 | engine *Engine | |
| 109 | 108 | } | |
| @@ -130,8 +129,6 @@ func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) | |||
| 130 | 129 | func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil } | |
| 131 | 130 | func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil } | |
| 132 | 131 | ||
| 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 with | 41 | + | // 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 *Engine | 44 | engine *Engine | ||
| 45 | bph purecef.BrowserProcessHandler | 45 | bph purecef.BrowserProcessHandler | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | - | // newDumberApp creates an App whose BrowserProcessHandler drives the engine's | 48 | + | // 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 sees | 57 | // 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. Only | 104 | + | // 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 *Engine | 107 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/json" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "sync/atomic" | |
| 8 | 7 | "time" | |
| 9 | - | "unicode/utf8" | |
| 10 | 8 | ||
| 11 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | - | "github.com/bnema/puregotk/v4/glib" | |
| 13 | 10 | ||
| 14 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | 12 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| @@ -33,23 +30,13 @@ type Engine struct { | |||
| 33 | 30 | ||
| 34 | 31 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 35 | 32 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 33 | + | activeCount atomic.Int32 | |
| 36 | 34 | ||
| 37 | 35 | // shutdownNotify is signalled when the active webview count reaches 0 | |
| 38 | 36 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 39 | 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 | 39 | // Diagnostic counters. | |
| 50 | - | pumpWorkCount atomic.Uint64 | |
| 51 | - | scheduleCount atomic.Uint64 // OnScheduleMessagePumpWork call count (currently always 0) | |
| 52 | - | ||
| 53 | 40 | contextInitializedCount atomic.Uint64 | |
| 54 | 41 | childLaunchRenderer atomic.Uint64 | |
| 55 | 42 | childLaunchGPU atomic.Uint64 | |
| @@ -62,6 +49,7 @@ type Engine struct { | |||
| 62 | 49 | browserCreateLastWidth atomic.Int32 | |
| 63 | 50 | browserCreateLastHeight atomic.Int32 | |
| 64 | 51 | lastStallLoggedCreateSeq atomic.Uint64 | |
| 52 | + | browserCreateComplete atomic.Bool | |
| 65 | 53 | ||
| 66 | 54 | transcoderState transcoderStartupState | |
| 67 | 55 | transcoderStateLogged atomic.Bool | |
| @@ -79,17 +67,14 @@ type transcoderStartupState struct { | |||
| 79 | 67 | Decoders []string | |
| 80 | 68 | } | |
| 81 | 69 | ||
| 82 | - | // Factory returns the WebViewFactory for creating new WebView instances. | |
| 83 | 70 | func (e *Engine) Factory() port.WebViewFactory { | |
| 84 | 71 | return &webViewFactoryAdapter{factory: e.factory} | |
| 85 | 72 | } | |
| 86 | 73 | ||
| 87 | - | // Pool returns the WebViewPool for acquiring pre-warmed WebView instances. | |
| 88 | 74 | func (e *Engine) Pool() port.WebViewPool { | |
| 89 | 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 | 78 | func (e *Engine) ContentInjector() port.ContentInjector { | |
| 94 | 79 | if e.contentInj != nil { | |
| 95 | 80 | return e.contentInj | |
| @@ -124,13 +109,14 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) { | |||
| 124 | 109 | // registerWebView adds a webview to the active tracking map. | |
| 125 | 110 | func (e *Engine) registerWebView(wv *WebView) { | |
| 126 | 111 | e.activeWebViews.Store(wv.id, wv) | |
| 112 | + | e.activeCount.Add(1) | |
| 127 | 113 | } | |
| 128 | 114 | ||
| 129 | 115 | // unregisterWebView removes a webview from the active tracking map. | |
| 130 | 116 | // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify. | |
| 131 | 117 | func (e *Engine) unregisterWebView(wv *WebView) { | |
| 132 | 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 | 120 | select { | |
| 135 | 121 | case e.shutdownNotify <- struct{}{}: | |
| 136 | 122 | default: | |
| @@ -154,7 +140,6 @@ func (e *Engine) Close() error { | |||
| 154 | 140 | Int("active_webviews", activeBefore). | |
| 155 | 141 | Msg("cef: engine close started") | |
| 156 | 142 | ||
| 157 | - | e.stopMessagePump() | |
| 158 | 143 | e.closeActiveWebViews() | |
| 159 | 144 | e.pool.Close() | |
| 160 | 145 | ||
| @@ -172,12 +157,7 @@ func (e *Engine) Close() error { | |||
| 172 | 157 | const cefShutdownWaitTimeout = 2 * time.Second | |
| 173 | 158 | ||
| 174 | 159 | 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()) | |
| 181 | 161 | } | |
| 182 | 162 | ||
| 183 | 163 | func (e *Engine) closeActiveWebViews() { | |
| @@ -239,54 +219,11 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||
| 239 | 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 | 222 | func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error { | |
| 244 | 223 | if e.messageRouter == nil || handler == nil { | |
| 245 | 224 | return nil | |
| 246 | 225 | } | |
| 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) | |
| 290 | 227 | } | |
| 291 | 228 | ||
| 292 | 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 | 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 | 237 | func (e *Engine) OnToolkitReady(_ context.Context) error { | |
| 300 | 238 | 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") | |
| 306 | 240 | return nil | |
| 307 | 241 | } | |
| 308 | 242 | ||
| @@ -383,6 +317,9 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) { | |||
| 383 | 317 | ||
| 384 | 318 | func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) { | |
| 385 | 319 | count := e.browserAfterCreated.Add(1) | |
| 320 | + | if count >= e.browserCreateRequests.Load() { | |
| 321 | + | e.browserCreateComplete.Store(true) | |
| 322 | + | } | |
| 386 | 323 | browserID := int32(0) | |
| 387 | 324 | if browser != nil { | |
| 388 | 325 | browserID = browser.GetIdentifier() | |
| @@ -395,6 +332,9 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) { | |||
| 395 | 332 | } | |
| 396 | 333 | ||
| 397 | 334 | func (e *Engine) maybeLogBrowserCreateStall() { | |
| 335 | + | if e.browserCreateComplete.Load() { | |
| 336 | + | return | |
| 337 | + | } | |
| 398 | 338 | createCount := e.browserCreateRequests.Load() | |
| 399 | 339 | if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() { | |
| 400 | 340 | return | |
| @@ -418,8 +358,6 @@ func (e *Engine) maybeLogBrowserCreateStall() { | |||
| 418 | 358 | Int32("last_create_result", e.browserCreateLastResult.Load()). | |
| 419 | 359 | Int32("last_create_width", e.browserCreateLastWidth.Load()). | |
| 420 | 360 | Int32("last_create_height", e.browserCreateLastHeight.Load()). | |
| 421 | - | Uint64("pump_work", e.pumpWorkCount.Load()). | |
| 422 | - | Uint64("schedule_calls", e.scheduleCount.Load()). | |
| 423 | 361 | Uint64("context_initialized", e.contextInitializedCount.Load()). | |
| 424 | 362 | Uint64("renderer_launches", e.childLaunchRenderer.Load()). | |
| 425 | 363 | Uint64("gpu_launches", e.childLaunchGPU.Load()). | |
| @@ -463,77 +401,3 @@ func (e *Engine) logTranscoderStartupState() { | |||
| 463 | 401 | ||
| 464 | 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 | - | } | |
| @@ -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]*WebView | 32 | activeWebViews sync.Map // map[port.WebViewID]*WebView | ||
| 33 | + | activeCount atomic.Int32 | |||
| 36 | 34 | ||||
| 37 | // shutdownNotify is signalled when the active webview count reaches 0 | 35 | // 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.Uint64 | 40 | contextInitializedCount atomic.Uint64 | ||
| 54 | childLaunchRenderer atomic.Uint64 | 41 | childLaunchRenderer atomic.Uint64 | ||
| 55 | childLaunchGPU atomic.Uint64 | 42 | childLaunchGPU atomic.Uint64 | ||
| @@ -62,6 +49,7 @@ type Engine struct { | |||||
| 62 | browserCreateLastWidth atomic.Int32 | 49 | browserCreateLastWidth atomic.Int32 | ||
| 63 | browserCreateLastHeight atomic.Int32 | 50 | browserCreateLastHeight atomic.Int32 | ||
| 64 | lastStallLoggedCreateSeq atomic.Uint64 | 51 | lastStallLoggedCreateSeq atomic.Uint64 | ||
| 52 | + | browserCreateComplete atomic.Bool | |||
| 65 | 53 | ||||
| 66 | transcoderState transcoderStartupState | 54 | transcoderState transcoderStartupState | ||
| 67 | transcoderStateLogged atomic.Bool | 55 | transcoderStateLogged atomic.Bool | ||
| @@ -79,17 +67,14 @@ type transcoderStartupState struct { | |||||
| 79 | Decoders []string | 67 | 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.contentInj | 80 | 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.Second | 157 | const cefShutdownWaitTimeout = 2 * time.Second | ||
| 173 | 158 | ||||
| 174 | func (e *Engine) activeWebViewCount() int { | 159 | func (e *Engine) activeWebViewCount() int { | ||
| 175 | - | count := 0 | 160 | + | 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 nil | 224 | 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 nil | 240 | 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 | return | 340 | 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 | |||
| 28 | 28 | logger := logging.FromContext(ctx) | |
| 29 | 29 | cleanStaleSingletonLocks(logger) | |
| 30 | 30 | ||
| 31 | - | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | |
| 32 | - | manualPumpMs := cfg.CEFManualPumpIntervalMs() | |
| 33 | 31 | windowlessFrameRate := cfg.CEFWindowlessFrameRate() | |
| 34 | 32 | ||
| 35 | 33 | settings := prepareCEFSettings(cfg, logger) | |
| @@ -40,21 +38,18 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c | |||
| 40 | 38 | os.Args = appendIfMissing(os.Args, "--no-zygote") | |
| 41 | 39 | ||
| 42 | 40 | eng := &Engine{ | |
| 43 | - | ctx: ctx, | |
| 44 | - | multiThreadedMessageLoop: multiThreaded, | |
| 45 | - | manualPumpInterval: manualPumpMs, | |
| 41 | + | ctx: ctx, | |
| 46 | 42 | } | |
| 47 | 43 | ||
| 48 | 44 | logger.Info(). | |
| 49 | - | Bool("multi_threaded_message_loop", multiThreaded). | |
| 50 | - | Int64("manual_pump_interval_ms", manualPumpMs). | |
| 51 | 45 | Int32("windowless_frame_rate", windowlessFrameRate). | |
| 46 | + | Bool("external_begin_frame", externalBeginFrameEnabled()). | |
| 52 | 47 | Bool("trace_handlers", cfg.TraceHandlers). | |
| 53 | 48 | Bool("enable_audio_handler", cfg.EnableAudioHandler). | |
| 54 | 49 | Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler). | |
| 55 | 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 | 53 | os.Args = savedArgs | |
| 59 | 54 | return nil, err | |
| 60 | 55 | } | |
| @@ -66,7 +61,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c | |||
| 66 | 61 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 67 | 62 | func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings { | |
| 68 | 63 | settings := purecef.DefaultSettings() | |
| 69 | - | settings.MultiThreadedMessageLoop = cfg.CEFMultiThreadedMessageLoop() | |
| 64 | + | settings.MultiThreadedMessageLoop = true | |
| 70 | 65 | settings.ExternalMessagePump = false | |
| 71 | 66 | if cfg.CEFDir != "" { | |
| 72 | 67 | settings.CEFDir = cfg.CEFDir | |
| @@ -97,22 +92,15 @@ func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) pure | |||
| 97 | 92 | return settings | |
| 98 | 93 | } | |
| 99 | 94 | ||
| 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) | |
| 115 | 102 | } | |
| 103 | + | logger.Debug().Msg("cef: InitWithApp returned OK") | |
| 116 | 104 | return nil | |
| 117 | 105 | } | |
| 118 | 106 | ||
| @@ -129,17 +117,6 @@ func wireEngine( | |||
| 129 | 117 | ||
| 130 | 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 | 120 | // Initialize the GPU transcoder when enabled in the loaded app config. | |
| 144 | 121 | var mediaTranscoder port.MediaTranscoder | |
| 145 | 122 | transcoderState := buildTranscoderStartupState(transcodingCfg) | |
| @@ -428,10 +405,5 @@ func prepareCEFInitTraceFile(cfg config.CEFEngineConfig) (string, error) { | |||
| 428 | 405 | } | |
| 429 | 406 | ||
| 430 | 407 | 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) | |
| 437 | 409 | } | |
| @@ -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 = savedArgs | 53 | os.Args = savedArgs | ||
| 59 | return nil, err | 54 | 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 = false | 65 | settings.ExternalMessagePump = false | ||
| 71 | if cfg.CEFDir != "" { | 66 | if cfg.CEFDir != "" { | ||
| 72 | settings.CEFDir = cfg.CEFDir | 67 | settings.CEFDir = cfg.CEFDir | ||
| @@ -97,22 +92,15 @@ func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) pure | |||||
| 97 | return settings | 92 | 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 nil | 104 | 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.MediaTranscoder | 121 | 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) { | |||
| 120 | 120 | // Configure WindowInfo for off-screen rendering (OSR). | |
| 121 | 121 | windowInfo := purecef.NewWindowInfo() | |
| 122 | 122 | windowInfo.WindowlessRenderingEnabled = 1 | |
| 123 | - | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 123 | + | if externalBeginFrameEnabled() { | |
| 124 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 125 | + | } | |
| 124 | 126 | ||
| 125 | 127 | // Configure BrowserSettings. | |
| 126 | 128 | 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 = 1 | 122 | windowInfo.WindowlessRenderingEnabled = 1 | ||
| 123 | - | windowInfo.ExternalBeginFrameEnabled = 1 | 123 | + | 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) { | |||
| 43 | 43 | fc.mu.Unlock() | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | // Search starts a new find-in-page search. | |
| 47 | 46 | func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) { | |
| 48 | 47 | fc.mu.Lock() | |
| 49 | 48 | fc.searchTxt = text | |
| @@ -103,7 +102,6 @@ func (fc *cefFindController) SearchPrevious() { | |||
| 103 | 102 | 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 | 105 | func (fc *cefFindController) SearchFinish() { | |
| 108 | 106 | fc.mu.Lock() | |
| 109 | 107 | 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 | 116 | func (fc *cefFindController) GetSearchText() string { | |
| 120 | 117 | fc.mu.RLock() | |
| 121 | 118 | 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 = text | 48 | fc.searchTxt = text | ||
| @@ -103,7 +102,6 @@ func (fc *cefFindController) SearchPrevious() { | |||||
| 103 | host.Find(text, 0, mc, 1) // forward=0, findnext=1 | 102 | 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.host | 107 | 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 ( | |||
| 14 | 14 | // Compile-time check: MessageRouter implements port.WebUIHandlerRouter. | |
| 15 | 15 | var _ port.WebUIHandlerRouter = (*MessageRouter)(nil) | |
| 16 | 16 | ||
| 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 | |
| 37 | 19 | ||
| 38 | 20 | type handlerEntry struct { | |
| 39 | - | handler MessageHandler | |
| 21 | + | handler portHandlerAdapter | |
| 40 | 22 | callback string | |
| 41 | 23 | errorCallback string | |
| 42 | 24 | 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 | 45 | func (r *MessageRouter) SetBaseContext(ctx context.Context) { | |
| 65 | 46 | if r == nil { | |
| 66 | 47 | return | |
| @@ -115,24 +96,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks( | |||
| 115 | 96 | return nil | |
| 116 | 97 | } | |
| 117 | 98 | ||
| 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. | |
| 136 | 100 | type portHandlerAdapter struct { | |
| 137 | 101 | inner port.WebUIMessageHandler | |
| 138 | 102 | } | |
| @@ -167,7 +131,7 @@ func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, bod | |||
| 167 | 131 | entry, ok := r.handlers[msg.Type] | |
| 168 | 132 | r.mu.RUnlock() | |
| 169 | 133 | ||
| 170 | - | if !ok || entry.handler == nil { | |
| 134 | + | if !ok || entry.handler.inner == nil { | |
| 171 | 135 | log.Warn().Str("type", msg.Type).Msg("no handler registered for message type") | |
| 172 | 136 | return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type}) | |
| 173 | 137 | } | |
| @@ -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 MessageHandler | 21 | + | handler portHandlerAdapter |
| 40 | callback string | 22 | callback string | ||
| 41 | errorCallback string | 23 | errorCallback string | ||
| 42 | world string | 24 | 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 | return | 47 | return | ||
| @@ -115,24 +96,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks( | |||||
| 115 | return nil | 96 | return nil | ||
| 116 | } | 97 | } | ||
| 117 | 98 | ||||
| 118 | - | // registerInternalHandler registers a CEF-internal handler that works with bare | 99 | + | // 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.WebUIMessageHandler | 101 | 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) { | |||
| 104 | 104 | p.Prewarm(count) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | // Size returns the number of available WebViews currently in the pool. | |
| 108 | 107 | func (p *WebViewPool) Size() int { | |
| 109 | 108 | p.mu.Lock() | |
| 110 | 109 | 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 { | |||
| 20 | 20 | // off-screen BGRA pixel buffers into a GtkGLArea widget. | |
| 21 | 21 | // | |
| 22 | 22 | // 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 | |
| 25 | 25 | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | |
| 26 | 26 | type renderPipeline struct { | |
| 27 | 27 | ctx context.Context | |
| @@ -38,20 +38,18 @@ type renderPipeline struct { | |||
| 38 | 38 | vbo uint32 | |
| 39 | 39 | ||
| 40 | 40 | // 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). | |
| 46 | 42 | widthAtomic atomic.Int32 | |
| 47 | 43 | heightAtomic atomic.Int32 | |
| 44 | + | scale int32 | |
| 48 | 45 | ||
| 49 | 46 | // 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 | |
| 55 | 53 | ||
| 56 | 54 | // Popup staging for native CEF widgets like <select> menus. | |
| 57 | 55 | popupVisible bool | |
| @@ -63,9 +61,12 @@ type renderPipeline struct { | |||
| 63 | 61 | popupSizeChanged bool | |
| 64 | 62 | ||
| 65 | 63 | // GL initialized flag. | |
| 66 | - | glReady bool | |
| 64 | + | 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 | 70 | viewRectSeq atomic.Uint64 | |
| 70 | 71 | screenInfoSeq atomic.Uint64 | |
| 71 | 72 | paintSeq atomic.Uint64 | |
| @@ -168,9 +169,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi | |||
| 168 | 169 | return rp | |
| 169 | 170 | } | |
| 170 | 171 | ||
| 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. | |
| 174 | 175 | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) { | |
| 175 | 176 | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 176 | 177 | return | |
| @@ -198,13 +199,12 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||
| 198 | 199 | sizeChanged := false | |
| 199 | 200 | ||
| 200 | 201 | // 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) { | |
| 204 | 203 | rp.widthAtomic.Store(width) | |
| 205 | 204 | rp.heightAtomic.Store(height) | |
| 206 | 205 | rp.staging = make([]byte, bufSize) | |
| 207 | 206 | rp.sizeChanged = true | |
| 207 | + | rp.forceFullUpload = true | |
| 208 | 208 | sizeChanged = true | |
| 209 | 209 | // On size change, copy the entire buffer. | |
| 210 | 210 | copiedBytes = uint64(copy(rp.staging, buffer)) | |
| @@ -228,21 +228,22 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||
| 228 | 228 | // next GL render would lose their dirty rect metadata — the pixels are | |
| 229 | 229 | // in staging but never uploaded to the texture, causing trails/ghosts. | |
| 230 | 230 | rp.dirtyRects = append(rp.dirtyRects, rects...) | |
| 231 | + | rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load()) | |
| 231 | 232 | rp.needsUpload = true | |
| 232 | 233 | ||
| 233 | 234 | rp.mu.Unlock() | |
| 234 | 235 | ||
| 235 | 236 | copyDuration := time.Since(startedAt) | |
| 237 | + | rp.diagMu.Lock() | |
| 236 | 238 | rp.paintCount++ | |
| 237 | - | rp.queueRenderCount++ | |
| 238 | 239 | rp.paintBytes += copiedBytes | |
| 239 | 240 | rp.dirtyRectCount += uint64(len(rects)) | |
| 240 | 241 | rp.paintCopyTotalNs += uint64(copyDuration) | |
| 241 | 242 | if copyDuration.Nanoseconds() > rp.maxPaintCopyNs { | |
| 242 | 243 | rp.maxPaintCopyNs = copyDuration.Nanoseconds() | |
| 243 | 244 | } | |
| 245 | + | rp.diagMu.Unlock() | |
| 244 | 246 | ||
| 245 | - | rp.glArea.QueueRender() | |
| 246 | 247 | if rp.ctx != nil { | |
| 247 | 248 | if sizeChanged && copiedBytes < uint64(bufSize) { | |
| 248 | 249 | logging.FromContext(rp.ctx).Warn(). | |
| @@ -263,6 +264,13 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||
| 263 | 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 | 274 | // onGLRender is the GTK "render" signal handler. GL context is current. | |
| 267 | 275 | func (rp *renderPipeline) onGLRender() bool { | |
| 268 | 276 | renderSeq := rp.glRenderSeq.Add(1) | |
| @@ -278,47 +286,46 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 278 | 286 | ||
| 279 | 287 | needsUpload := rp.needsUpload | |
| 280 | 288 | 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() | |
| 290 | 291 | ||
| 291 | 292 | rp.mu.Unlock() | |
| 292 | 293 | ||
| 293 | 294 | gl := rp.gl | |
| 294 | 295 | ||
| 295 | 296 | if !rp.glReady || sizeChanged { | |
| 296 | - | rp.initGL() | |
| 297 | + | rp.initGL(uploadWidth, uploadHeight) | |
| 297 | 298 | } | |
| 298 | 299 | ||
| 299 | 300 | if !rp.glReady { | |
| 300 | - | rp.glRenderCount++ | |
| 301 | 301 | renderDuration := time.Since(renderStartedAt) | |
| 302 | + | rp.diagMu.Lock() | |
| 303 | + | rp.glRenderCount++ | |
| 302 | 304 | rp.renderTotalNs += uint64(renderDuration) | |
| 303 | 305 | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 304 | 306 | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 305 | 307 | } | |
| 308 | + | rp.diagMu.Unlock() | |
| 306 | 309 | rp.maybeLogDiagnostics() | |
| 307 | 310 | return true | |
| 308 | 311 | } | |
| 309 | 312 | ||
| 310 | 313 | if needsUpload { | |
| 311 | 314 | 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() | |
| 322 | 329 | } | |
| 323 | 330 | } | |
| 324 | 331 | ||
| @@ -333,12 +340,14 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 333 | 340 | rp.drawPopupOverlay(w, h) | |
| 334 | 341 | } | |
| 335 | 342 | ||
| 336 | - | rp.glRenderCount++ | |
| 337 | 343 | renderDuration := time.Since(renderStartedAt) | |
| 344 | + | rp.diagMu.Lock() | |
| 345 | + | rp.glRenderCount++ | |
| 338 | 346 | rp.renderTotalNs += uint64(renderDuration) | |
| 339 | 347 | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 340 | 348 | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 341 | 349 | } | |
| 350 | + | rp.diagMu.Unlock() | |
| 342 | 351 | rp.maybeLogDiagnostics() | |
| 343 | 352 | if rp.ctx != nil { | |
| 344 | 353 | logging.FromContext(rp.ctx).Trace(). | |
| @@ -368,19 +377,27 @@ func (rp *renderPipeline) drawTexture(texture uint32) { | |||
| 368 | 377 | } | |
| 369 | 378 | ||
| 370 | 379 | // 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) { | |
| 373 | 384 | 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 | 395 | if rp.ctx != nil { | |
| 377 | 396 | logging.FromContext(rp.ctx).Trace(). | |
| 378 | 397 | Uint64("render_seq", renderSeq). | |
| 379 | 398 | Uint64("paint_seq", paintSeq). | |
| 380 | 399 | Int32("width", w). | |
| 381 | 400 | Int32("height", h). | |
| 382 | - | Int("dirty_rect_count", len(dirtyRects)). | |
| 383 | - | Bool("full_upload", fullUpload). | |
| 384 | 401 | Msg("cef: uploadToPBO begin") | |
| 385 | 402 | } | |
| 386 | 403 | ||
| @@ -395,13 +412,42 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||
| 395 | 412 | mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly) | |
| 396 | 413 | if mapped == nil { | |
| 397 | 414 | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 398 | - | return 0 | |
| 415 | + | return 0, false, false | |
| 399 | 416 | } | |
| 400 | 417 | ||
| 401 | 418 | mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize)) | |
| 402 | 419 | uploadedBytes := uint64(0) | |
| 420 | + | fullUpload := false | |
| 421 | + | dirtyRects := make([]rect, 0) | |
| 403 | 422 | ||
| 404 | 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 | 451 | if fullUpload { | |
| 406 | 452 | copied := copy(mappedSlice, rp.staging) | |
| 407 | 453 | if copied < len(mappedSlice) { | |
| @@ -409,19 +455,12 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||
| 409 | 455 | } | |
| 410 | 456 | uploadedBytes = uint64(copied) | |
| 411 | 457 | } 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) | |
| 424 | 460 | } | |
| 461 | + | rp.needsUpload = false | |
| 462 | + | rp.forceFullUpload = false | |
| 463 | + | rp.dirtyRects = rp.dirtyRects[:0] | |
| 425 | 464 | rp.mu.Unlock() | |
| 426 | 465 | ||
| 427 | 466 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| @@ -433,7 +472,7 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||
| 433 | 472 | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |
| 434 | 473 | } | |
| 435 | 474 | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 436 | - | return 0 | |
| 475 | + | return 0, fullUpload, false | |
| 437 | 476 | } | |
| 438 | 477 | ||
| 439 | 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 | 506 | Uint64("render_seq", renderSeq). | |
| 468 | 507 | Uint64("paint_seq", paintSeq). | |
| 469 | 508 | Uint64("uploaded_bytes", uploadedBytes). | |
| 509 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 510 | + | Bool("full_upload", fullUpload). | |
| 470 | 511 | Int("next_pbo_index", rp.pboIndex). | |
| 471 | 512 | Msg("cef: uploadToPBO end") | |
| 472 | 513 | } | |
| 473 | - | return uploadedBytes | |
| 514 | + | return uploadedBytes, fullUpload, true | |
| 474 | 515 | } | |
| 475 | 516 | ||
| 476 | 517 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | |
| @@ -577,15 +618,9 @@ func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) { | |||
| 577 | 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. | |
| 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 { | |
| 589 | 624 | return | |
| 590 | 625 | } | |
| 591 | 626 | ||
| @@ -596,6 +631,15 @@ func (rp *renderPipeline) initGL() { | |||
| 596 | 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 | 643 | // Texture. | |
| 600 | 644 | gl.genTextures(1, &rp.texture) | |
| 601 | 645 | gl.bindTexture(glTexture2D, rp.texture) | |
| @@ -603,11 +647,11 @@ func (rp *renderPipeline) initGL() { | |||
| 603 | 647 | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | |
| 604 | 648 | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | |
| 605 | 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 | 651 | gl.bindTexture(glTexture2D, 0) | |
| 608 | 652 | ||
| 609 | 653 | // PBOs. | |
| 610 | - | bufSize := int64(w) * int64(h) * 4 | |
| 654 | + | bufSize := int64(width) * int64(height) * 4 | |
| 611 | 655 | gl.genBuffers(1, &rp.pbo[0]) | |
| 612 | 656 | gl.genBuffers(1, &rp.pbo[1]) | |
| 613 | 657 | for i := 0; i < 2; i++ { | |
| @@ -650,12 +694,18 @@ func (rp *renderPipeline) initGL() { | |||
| 650 | 694 | } | |
| 651 | 695 | ||
| 652 | 696 | func (rp *renderPipeline) recordAcceleratedPaint() uint64 { | |
| 697 | + | rp.diagMu.Lock() | |
| 653 | 698 | rp.acceleratedPaintCount++ | |
| 699 | + | count := rp.acceleratedPaintCount | |
| 700 | + | rp.diagMu.Unlock() | |
| 654 | 701 | rp.maybeLogDiagnostics() | |
| 655 | - | return rp.acceleratedPaintCount | |
| 702 | + | return count | |
| 656 | 703 | } | |
| 657 | 704 | ||
| 658 | 705 | func (rp *renderPipeline) maybeLogDiagnostics() { | |
| 706 | + | rp.diagMu.Lock() | |
| 707 | + | defer rp.diagMu.Unlock() | |
| 708 | + | ||
| 659 | 709 | now := time.Now() | |
| 660 | 710 | if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second { | |
| 661 | 711 | return | |
| @@ -777,10 +827,9 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 777 | 827 | return v * rp.scale | |
| 778 | 828 | } | |
| 779 | 829 | ||
| 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) | |
| 784 | 833 | rp.sizeChanged = true | |
| 785 | 834 | ||
| 786 | 835 | firstCB := rp.onFirstResize | |
| @@ -788,8 +837,6 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 788 | 837 | rp.onFirstResize = nil | |
| 789 | 838 | } | |
| 790 | 839 | resizeCB := rp.onResizeCB | |
| 791 | - | ||
| 792 | - | w, h := rp.width, rp.height | |
| 793 | 840 | rp.mu.Unlock() | |
| 794 | 841 | ||
| 795 | 842 | if firstCB != nil { | |
| @@ -866,12 +913,6 @@ func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, p | |||
| 866 | 913 | func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |
| 867 | 914 | width = rp.widthAtomic.Load() | |
| 868 | 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 | 916 | scale = rp.scale | |
| 876 | 917 | if scale <= 0 { | |
| 877 | 918 | scale = 1 | |
| @@ -953,6 +994,8 @@ func (rp *renderPipeline) deleteGLResources() { | |||
| 953 | 994 | // glDeleteProgram to glLoader if needed. | |
| 954 | 995 | rp.program = 0 | |
| 955 | 996 | } | |
| 997 | + | rp.glWidth = 0 | |
| 998 | + | rp.glHeight = 0 | |
| 956 | 999 | } | |
| 957 | 1000 | ||
| 958 | 1001 | func bgraBufferSize(width, height int32) (int, bool) { | |
| @@ -966,6 +1009,88 @@ func bgraBufferSize(width, height int32) (int, bool) { | |||
| 966 | 1009 | 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 | 1094 | func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool { | |
| 970 | 1095 | return staging == nil || | |
| 971 | 1096 | len(staging) != expectedBytes || | |
| @@ -1038,6 +1163,68 @@ func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rec | |||
| 1038 | 1163 | 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 | 1228 | // pboOffset converts a byte offset into an unsafe.Pointer for use with | |
| 1042 | 1229 | // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a | |
| 1043 | 1230 | // 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). handlePaint | 22 | // 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 a | 24 | + | // 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.Context | 27 | ctx context.Context | ||
| @@ -38,20 +38,18 @@ type renderPipeline struct { | |||||
| 38 | vbo uint32 | 38 | vbo uint32 | ||
| 39 | 39 | ||||
| 40 | // Surface dimensions (in device pixels, i.e. scaled). | 40 | // Surface dimensions (in device pixels, i.e. scaled). | ||
| 41 | - | width int32 | 41 | + | // 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.Int32 | 42 | widthAtomic atomic.Int32 | ||
| 47 | heightAtomic atomic.Int32 | 43 | 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.Mutex | 47 | + | mu sync.Mutex |
| 51 | - | staging []byte | 48 | + | staging []byte |
| 52 | - | dirtyRects []rect | 49 | + | dirtyRects []rect |
| 53 | - | needsUpload bool | 50 | + | needsUpload bool |
| 54 | - | sizeChanged bool | 51 | + | 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 bool | 55 | popupVisible bool | ||
| @@ -63,9 +61,12 @@ type renderPipeline struct { | |||||
| 63 | popupSizeChanged bool | 61 | popupSizeChanged bool | ||
| 64 | 62 | ||||
| 65 | // GL initialized flag. | 63 | // GL initialized flag. | ||
| 66 | - | glReady bool | 64 | + | 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.Uint64 | 70 | viewRectSeq atomic.Uint64 | ||
| 70 | screenInfoSeq atomic.Uint64 | 71 | screenInfoSeq atomic.Uint64 | ||
| 71 | paintSeq atomic.Uint64 | 72 | paintSeq atomic.Uint64 | ||
| @@ -168,9 +169,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi | |||||
| 168 | return rp | 169 | return rp | ||
| 169 | } | 170 | } | ||
| 170 | 171 | ||||
| 171 | - | // handlePaint is called from CEF's OnPaint callback (on GTK main thread via | 172 | + | // 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 staging | 173 | + | // 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 | return | 177 | return | ||
| @@ -198,13 +199,12 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||||
| 198 | sizeChanged := false | 199 | 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 = true | 206 | rp.sizeChanged = true | ||
| 207 | + | rp.forceFullUpload = true | |||
| 208 | sizeChanged = true | 208 | 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 are | 228 | // 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 = true | 232 | 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 += copiedBytes | 239 | 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.needsUpload | 287 | needsUpload := rp.needsUpload | ||
| 280 | sizeChanged := rp.sizeChanged | 288 | sizeChanged := rp.sizeChanged | ||
| 281 | - | rp.needsUpload = false | 289 | + | 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.gl | 294 | 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 true | 310 | 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 += uploadedBytes | 321 | + | 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, then | 379 | // 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.gl | 384 | 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 0 | 415 | + | 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 0 | 475 | + | 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 uploadedBytes | 514 | + | 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 | return | 624 | 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) * 4 | 654 | + | 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.acceleratedPaintCount | 702 | + | 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 | return | 711 | return | ||
| @@ -777,10 +827,9 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 777 | return v * rp.scale | 827 | 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 = true | 833 | rp.sizeChanged = true | ||
| 785 | 834 | ||||
| 786 | firstCB := rp.onFirstResize | 835 | firstCB := rp.onFirstResize | ||
| @@ -788,8 +837,6 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 788 | rp.onFirstResize = nil | 837 | rp.onFirstResize = nil | ||
| 789 | } | 838 | } | ||
| 790 | resizeCB := rp.onResizeCB | 839 | 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.scale | 916 | scale = rp.scale | ||
| 876 | if scale <= 0 { | 917 | if scale <= 0 { | ||
| 877 | scale = 1 | 918 | 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 = 0 | 995 | 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), true | 1009 | 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, truncated | 1163 | 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 with | 1228 | // pboOffset converts a byte offset into an unsafe.Pointer for use with | ||
| 1042 | // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a | 1229 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "sort" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 7 | 10 | "github.com/stretchr/testify/require" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| @@ -39,3 +42,202 @@ func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) { | |||
| 39 | 42 | require.Equal(t, src, dst[:len(src)]) | |
| 40 | 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 | + | } | |
| @@ -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 ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "html" | |
| 10 | 10 | "io/fs" | |
| 11 | - | "mime" | |
| 12 | 11 | "net/http" | |
| 13 | 12 | "net/url" | |
| 14 | 13 | "path/filepath" | |
| @@ -22,6 +21,7 @@ import ( | |||
| 22 | 21 | "github.com/bnema/dumber/internal/application/port" | |
| 23 | 22 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 24 | 23 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 24 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 25 | 25 | "github.com/bnema/dumber/internal/logging" | |
| 26 | 26 | "github.com/rs/zerolog" | |
| 27 | 27 | ) | |
| @@ -35,6 +35,14 @@ const ( | |||
| 35 | 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 | 46 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| 39 | 47 | // internal https://dumber.invalid origin used by CEF. | |
| 40 | 48 | type dumbSchemeHandler struct { | |
| @@ -313,14 +321,7 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) { | |||
| 313 | 321 | return "", false | |
| 314 | 322 | } | |
| 315 | 323 | ||
| 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 { | |
| 324 | 325 | path := strings.TrimPrefix(u.Path, "/") | |
| 325 | 326 | if path == "" { | |
| 326 | 327 | return root, true | |
| @@ -333,18 +334,10 @@ func resolveConceptualAssetPath(u *url.URL) (string, bool) { | |||
| 333 | 334 | } | |
| 334 | 335 | ||
| 335 | 336 | // 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 | |
| 347 | 339 | } | |
| 340 | + | return "", false | |
| 348 | 341 | } | |
| 349 | 342 | ||
| 350 | 343 | func resolveActualAssetPath(u *url.URL) (string, bool) { | |
| @@ -357,13 +350,7 @@ func resolveActualAssetPath(u *url.URL) (string, bool) { | |||
| 357 | 350 | return "", false | |
| 358 | 351 | } | |
| 359 | 352 | ||
| 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 { | |
| 367 | 354 | return root, true | |
| 368 | 355 | } | |
| 369 | 356 | ||
| @@ -398,151 +385,15 @@ func isCEFCrashPageURL(u *url.URL) bool { | |||
| 398 | 385 | } | |
| 399 | 386 | ||
| 400 | 387 | 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) | |
| 422 | 389 | } | |
| 423 | 390 | ||
| 424 | 391 | 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) | |
| 501 | 393 | } | |
| 502 | 394 | ||
| 503 | - | // getMimeType determines the MIME type for a given file path. | |
| 504 | 395 | 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) | |
| 546 | 397 | } | |
| 547 | 398 | ||
| 548 | 399 | // 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 actual | 46 | // 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 "", false | 321 | 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, true | 327 | 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 "", false | 350 | 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, true | 354 | 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() { | |||
| 102 | 102 | // Identity | |
| 103 | 103 | // --------------------------------------------------------------------------- | |
| 104 | 104 | ||
| 105 | - | // ID returns the unique identifier for this WebView. | |
| 106 | 105 | func (wv *WebView) ID() port.WebViewID { | |
| 107 | 106 | return wv.id | |
| 108 | 107 | } | |
| @@ -111,7 +110,6 @@ func (wv *WebView) ID() port.WebViewID { | |||
| 111 | 110 | // Navigation | |
| 112 | 111 | // --------------------------------------------------------------------------- | |
| 113 | 112 | ||
| 114 | - | // LoadURI navigates to the specified URI. | |
| 115 | 113 | func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |
| 116 | 114 | if wv.destroyed.Load() { | |
| 117 | 115 | return errDestroyed | |
| @@ -156,7 +154,6 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error { | |||
| 156 | 154 | return nil | |
| 157 | 155 | } | |
| 158 | 156 | ||
| 159 | - | // Reload reloads the current page. | |
| 160 | 157 | func (wv *WebView) Reload(_ context.Context) error { | |
| 161 | 158 | if wv.destroyed.Load() { | |
| 162 | 159 | return errDestroyed | |
| @@ -235,21 +232,18 @@ func (wv *WebView) GoForward(_ context.Context) error { | |||
| 235 | 232 | // State queries (read from cache under RLock) | |
| 236 | 233 | // --------------------------------------------------------------------------- | |
| 237 | 234 | ||
| 238 | - | // URI returns the current URI. | |
| 239 | 235 | func (wv *WebView) URI() string { | |
| 240 | 236 | wv.mu.RLock() | |
| 241 | 237 | defer wv.mu.RUnlock() | |
| 242 | 238 | return wv.uri | |
| 243 | 239 | } | |
| 244 | 240 | ||
| 245 | - | // Title returns the current page title. | |
| 246 | 241 | func (wv *WebView) Title() string { | |
| 247 | 242 | wv.mu.RLock() | |
| 248 | 243 | defer wv.mu.RUnlock() | |
| 249 | 244 | return wv.title | |
| 250 | 245 | } | |
| 251 | 246 | ||
| 252 | - | // IsLoading returns true if a page is currently loading. | |
| 253 | 247 | func (wv *WebView) IsLoading() bool { | |
| 254 | 248 | wv.mu.RLock() | |
| 255 | 249 | defer wv.mu.RUnlock() | |
| @@ -461,12 +455,10 @@ func (wv *WebView) ResetBackgroundToDefault() { | |||
| 461 | 455 | // Lifecycle | |
| 462 | 456 | // --------------------------------------------------------------------------- | |
| 463 | 457 | ||
| 464 | - | // IsDestroyed returns true if the WebView has been destroyed. | |
| 465 | 458 | func (wv *WebView) IsDestroyed() bool { | |
| 466 | 459 | return wv.destroyed.Load() | |
| 467 | 460 | } | |
| 468 | 461 | ||
| 469 | - | // Destroy releases all resources associated with this WebView. | |
| 470 | 462 | func (wv *WebView) Destroy() { | |
| 471 | 463 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 472 | 464 | return | |
| @@ -621,6 +613,9 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||
| 621 | 613 | } | |
| 622 | 614 | ||
| 623 | 615 | func (wv *WebView) scheduleStartBeginFrameLoop() { | |
| 616 | + | if !externalBeginFrameEnabled() { | |
| 617 | + | return | |
| 618 | + | } | |
| 624 | 619 | wv.runOnGTK(func() { | |
| 625 | 620 | wv.startBeginFrameLoop() | |
| 626 | 621 | }) | |
| @@ -689,7 +684,8 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 689 | 684 | if fn == nil { | |
| 690 | 685 | 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 | 689 | fn() | |
| 694 | 690 | return | |
| 695 | 691 | } | |
| @@ -102,7 +102,6 @@ func (fn cefTaskFunc) Execute() { | |||||
| 102 | // Identity | 102 | // 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.id | 106 | return wv.id | ||
| 108 | } | 107 | } | ||
| @@ -111,7 +110,6 @@ func (wv *WebView) ID() port.WebViewID { | |||||
| 111 | // Navigation | 110 | // 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 errDestroyed | 115 | return errDestroyed | ||
| @@ -156,7 +154,6 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error { | |||||
| 156 | return nil | 154 | 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 errDestroyed | 159 | 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.uri | 238 | 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.title | 244 | 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 | // Lifecycle | 455 | // 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 | return | 464 | 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 | return | 685 | 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 | return | 690 | 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) { | |||
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | 190 | // 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. | |
| 193 | 195 | func (h *handlerSet) OnPaint( | |
| 194 | 196 | _ purecef.Browser, elementType purecef.PaintElementType, | |
| 195 | 197 | dirtyRects []purecef.Rect, buffer []byte, width, height int32, | |
| @@ -211,32 +213,29 @@ func (h *handlerSet) OnPaint( | |||
| 211 | 213 | for i, dr := range dirtyRects { | |
| 212 | 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 | 218 | pixels := make([]byte, len(buffer)) | |
| 216 | 219 | copy(pixels, buffer) | |
| 217 | 220 | 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) | |
| 223 | 222 | }) | |
| 224 | 223 | if h.wv != nil && h.wv.ctx != nil { | |
| 225 | 224 | logging.FromContext(h.wv.ctx).Trace(). | |
| 226 | 225 | Uint64("paint_seq", paintSeq). | |
| 227 | - | Msg("cef: OnPaint queued to GTK") | |
| 226 | + | Msg("cef: OnPaint queued popup to GTK") | |
| 228 | 227 | } | |
| 229 | 228 | return | |
| 230 | 229 | } | |
| 231 | - | if elementType == purecef.PaintElementTypePetPopup { | |
| 232 | - | h.wv.pipeline.handlePopupPaint(buffer, width, height, paintSeq) | |
| 233 | - | return | |
| 234 | - | } | |
| 230 | + | ||
| 235 | 231 | h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq) | |
| 232 | + | h.wv.runOnGTK(func() { | |
| 233 | + | h.wv.pipeline.queuePaintRender() | |
| 234 | + | }) | |
| 236 | 235 | if h.wv != nil && h.wv.ctx != nil { | |
| 237 | 236 | logging.FromContext(h.wv.ctx).Trace(). | |
| 238 | 237 | Uint64("paint_seq", paintSeq). | |
| 239 | - | Msg("cef: OnPaint handled inline") | |
| 238 | + | Msg("cef: OnPaint queued to GTK") | |
| 240 | 239 | } | |
| 241 | 240 | } | |
| 242 | 241 | ||
| @@ -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 rects | 190 | // 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 | 191 | + | // 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 | return | 228 | 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 ( | |||
| 10 | 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 | 20 | type stubFrame struct { | |
| 14 | 21 | main bool | |
| 15 | 22 | url string | |
| @@ -65,12 +72,8 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |||
| 65 | 72 | rect := &purecef.Rect{} | |
| 66 | 73 | h := &handlerSet{ | |
| 67 | 74 | 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), | |
| 74 | 77 | }, | |
| 75 | 78 | } | |
| 76 | 79 | ||
| @@ -87,12 +90,8 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||
| 87 | 90 | info := &si | |
| 88 | 91 | h := &handlerSet{ | |
| 89 | 92 | 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), | |
| 96 | 95 | }, | |
| 97 | 96 | } | |
| 98 | 97 | ||
| @@ -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 bool | 21 | main bool | ||
| 15 | url string | 22 | 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 := &si | 90 | 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 ( | |||
| 68 | 68 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 69 | 69 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 70 | 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | |
| 71 | - | defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval | |
| 72 | 71 | ||
| 73 | 72 | // Transcoding defaults | |
| 74 | 73 | defaultTranscodingEnabled = true | |
| @@ -92,8 +91,6 @@ func getDefaultLogDir() string { | |||
| 92 | 91 | ||
| 93 | 92 | // DefaultConfig returns the default configuration values for dumber. | |
| 94 | 93 | func DefaultConfig() *Config { | |
| 95 | - | defaultCEFMultiThreadedMessageLoop := true | |
| 96 | - | ||
| 97 | 94 | return &Config{ | |
| 98 | 95 | Database: DatabaseConfig{ | |
| 99 | 96 | // Path is set dynamically in config.Load() | |
| @@ -162,10 +159,8 @@ func DefaultConfig() *Config { | |||
| 162 | 159 | // matches Epiphany's model and avoids a misleading setting. | |
| 163 | 160 | CookiePolicy: CookiePolicyAlways, | |
| 164 | 161 | CEF: CEFEngineConfig{ | |
| 165 | - | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 166 | - | MultiThreadedMessageLoop: &defaultCEFMultiThreadedMessageLoop, | |
| 167 | - | ManualPumpIntervalMs: defaultCEFManualPumpIntervalMs, | |
| 168 | - | LogFile: "", | |
| 162 | + | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 163 | + | LogFile: "", | |
| 169 | 164 | }, | |
| 170 | 165 | WebKit: WebKitEngineConfig{ | |
| 171 | 166 | 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 startup | 69 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | ||
| 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | 70 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | ||
| 71 | - | defaultCEFManualPumpIntervalMs = 10 // CEF manual message pump interval | |||
| 72 | 71 | ||||
| 73 | // Transcoding defaults | 72 | // Transcoding defaults | ||
| 74 | defaultTranscodingEnabled = true | 73 | 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) { | |||
| 21 | 21 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | |
| 22 | 22 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | |
| 23 | 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 | 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 27 | 25 | assert.False(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 28 | 26 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import "os" | |
| 4 | 4 | ||
| 5 | + | const ( | |
| 6 | + | EngineTypeWebKit = "webkit" | |
| 7 | + | EngineTypeCEF = "cef" | |
| 8 | + | ) | |
| 9 | + | ||
| 5 | 10 | // ResolveEngineType returns the effective engine type from config + env override. | |
| 6 | 11 | // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE | |
| 7 | 12 | // to override the configured value for smoke testing. | |
| 8 | 13 | func (e *EngineConfig) ResolveEngineType() string { | |
| 9 | 14 | engineType := e.Type | |
| 10 | 15 | if engineType == "" { | |
| 11 | - | engineType = "webkit" | |
| 16 | + | engineType = EngineTypeWebKit | |
| 12 | 17 | } | |
| 13 | 18 | if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" { | |
| 14 | 19 | engineType = envEngine | |
| @@ -88,12 +93,6 @@ type CEFEngineConfig struct { | |||
| 88 | 93 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | |
| 89 | 94 | // Default: 60. Higher values increase CPU usage. | |
| 90 | 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 | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 98 | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 99 | 98 | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | |
| @@ -102,29 +101,14 @@ type CEFEngineConfig struct { | |||
| 102 | 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. | |
| 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. | |
| 123 | 107 | func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 { | |
| 124 | 108 | if c.WindowlessFrameRate > 0 { | |
| 125 | 109 | return c.WindowlessFrameRate | |
| 126 | 110 | } | |
| 127 | - | return 0 // auto-detect from monitor refresh rate | |
| 111 | + | return defaultCEFWindowlessFrameRate | |
| 128 | 112 | } | |
| 129 | 113 | ||
| 130 | 114 | // 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_ENGINE | 11 | // 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.Type | 14 | 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 = envEngine | 19 | 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.WindowlessFrameRate | 109 | return c.WindowlessFrameRate | ||
| 126 | } | 110 | } | ||
| 127 | - | return 0 // auto-detect from monitor refresh rate | 111 | + | return defaultCEFWindowlessFrameRate |
| 128 | } | 112 | } | ||
| 129 | 113 | ||||
| 130 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | 114 | // 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) { | |||
| 574 | 574 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | |
| 575 | 575 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | |
| 576 | 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 | 577 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 580 | 578 | m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler) | |
| 581 | 579 | 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 | |||
| 919 | 919 | Range: ">=0", | |
| 920 | 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 | 923 | Key: "engine.webkit.skia_cpu_painting_threads", | |
| 939 | 924 | 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 { | |||
| 341 | 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 | 344 | return validationErrors | |
| 349 | 345 | } | |
| 350 | 346 | ||
| @@ -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 validationErrors | 344 | 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) { | |||
| 63 | 63 | }, | |
| 64 | 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 | 67 | name: "negative frame rate", | |
| 75 | 68 | mutate: func(cfg *Config) { | |
| @@ -78,14 +71,6 @@ func TestValidateConfig_CEFConfig(t *testing.T) { | |||
| 78 | 71 | wantErr: true, | |
| 79 | 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 | 76 | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/handlers/homepage" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // AccentKeyHandler is implemented by the InsertAccentUseCase to receive | |
| @@ -81,6 +82,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h | |||
| 81 | 82 | } | |
| 82 | 83 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 83 | 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 | 88 | return nil, nil | |
| 86 | 89 | }, | |
| @@ -98,6 +101,8 @@ func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, h | |||
| 98 | 101 | } | |
| 99 | 102 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 100 | 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 | 107 | return nil, nil | |
| 103 | 108 | }, | |
| @@ -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 receive | 14 | // 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, nil | 88 | 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, nil | 107 | 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) ( | |||
| 73 | 73 | return nil, errors.New("failed to allocate audio encoder context") | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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)) | |
| 78 | 79 | ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate) | |
| 79 | 80 | codecCtxSetBitRate(encCtx, opusBitrate) | |
| 80 | 81 | ||
| @@ -116,7 +117,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 116 | 117 | ||
| 117 | 118 | inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx) | |
| 118 | 119 | inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx) | |
| 119 | - | needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFltp) | |
| 120 | + | needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt) | |
| 120 | 121 | ||
| 121 | 122 | if needResample { | |
| 122 | 123 | swr = ffmpeg.SwrAlloc() | |
| @@ -130,7 +131,7 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 130 | 131 | ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0) | |
| 131 | 132 | ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0) | |
| 132 | 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 | 136 | // Modern FFmpeg (8.x) requires explicit channel layout configuration | |
| 136 | 137 | // 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 | 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 | 161 | frameSetSampleRate(resampledFrame, opusSampleRate) | |
| 161 | 162 | frameSetNbSamples(resampledFrame, opusFrameSize) | |
| 162 | 163 | // 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 configuration | 136 | // Modern FFmpeg (8.x) requires explicit channel layout configuration | ||
| 136 | // on the SwrContext. Without it, swr_init may succeed but the resampler | 137 | // 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{ | |||
| 34 | 34 | // streamingManifestMIMEs lists manifest/container MIME types that usually | |
| 35 | 35 | // resolve to proprietary segment streams via HLS or DASH. | |
| 36 | 36 | 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 | |
| 39 | 41 | "application/dash+xml": true, | |
| 40 | 42 | } | |
| 41 | 43 | ||
| @@ -34,8 +34,10 @@ var openVideoMIMEs = map[string]bool{ | |||||
| 34 | // streamingManifestMIMEs lists manifest/container MIME types that usually | 34 | // 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) { | |||
| 93 | 93 | }{ | |
| 94 | 94 | {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true}, | |
| 95 | 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 | 98 | {name: "dash", contentType: "application/dash+xml", want: true}, | |
| 97 | 99 | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |
| 98 | 100 | {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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 14 | ) | |
| @@ -28,91 +29,6 @@ const themeCSSScript = `(function() { | |||
| 28 | 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 | 32 | // internalPageAllowList restricts script injection to internal dumb:// pages only. | |
| 117 | 33 | var internalPageAllowList = []string{"dumb://*"} | |
| 118 | 34 | ||
| @@ -333,7 +249,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 333 | 249 | ||
| 334 | 250 | // 3. Inject dark mode handler for internal pages only | |
| 335 | 251 | // 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 | 253 | addScript( | |
| 338 | 254 | webkit.NewUserScript( | |
| 339 | 255 | internalDarkModeScript, | |
| @@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 347 | 263 | ||
| 348 | 264 | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 349 | 265 | 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) | |
| 354 | 267 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | |
| 355 | 268 | addScript( | |
| 356 | 269 | 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 only | 250 | // 3. Inject dark mode handler for internal pages only | ||
| 335 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 251 | // 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 literal | 266 | + | 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 ( | |||
| 13 | 13 | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 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 | |
| 23 | 18 | ||
| 24 | 19 | // MessageHandler is an alias for port.WebUIMessageHandler. | |
| 25 | 20 | // 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) { | |||
| 204 | 204 | }) | |
| 205 | 205 | glib.IdleAdd(&cb, 0) | |
| 206 | 206 | }, | |
| 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, | |
| 212 | 208 | }); err != nil { | |
| 213 | 209 | return nil, err | |
| 214 | 210 | } | |
| @@ -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, err | 209 | return nil, err | ||
| 214 | } | 210 | } | ||