detached 48a7b55

Merge branch 'feat/cef-engine-adapter'

commit
48a7b55ee9f03583946a6e83f3476e1e081e5260
author
brice · · 2026-04-11 16:40 UTC
merge of
0ceb330 + 9904d0f · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
154 files · +15560 -2107
M .gitignore +3 -0
@@ -8,6 +8,7 @@ dist/
8 8
9 # Binaries for programs and plugins9 # Binaries for programs and plugins
10 /dumber10 /dumber
11+/cef-helper
11 dumber.exe12 dumber.exe
12 13
13 # Test binary, built with `go test -c`14 # Test binary, built with `go test -c`
@@ -42,6 +43,7 @@ Thumbs.db
42 *.tmp43 *.tmp
43 *.temp44 *.temp
44 *.log45 *.log
46+__debug_bin*
45 47
46 # Database files48 # Database files
47 *.db49 *.db
@@ -116,3 +118,4 @@ aur/*/logo-512.png
116 118
117 # Planning/runbook notes stored outside product docs119 # Planning/runbook notes stored outside product docs
118 docs/reference/stability-performance-multi-agent-runbook-*.md120 docs/reference/stability-performance-multi-agent-runbook-*.md
121+perf.data
M .golangci.yml +10 -0
@@ -146,6 +146,11 @@ linters:
146 - text: "G204:"146 - text: "G204:"
147 linters:147 linters:
148 - gosec148 - gosec
149+ # CEF/OpenGL interop requires unsafe pointer arithmetic
150+ - path: internal/infrastructure/cef/
151+ text: "G103:"
152+ linters:
153+ - gosec
149 - text: "G304:"154 - text: "G304:"
150 linters:155 linters:
151 - gosec156 - gosec
@@ -171,6 +176,11 @@ linters:
171 text: "unused-receiver"176 text: "unused-receiver"
172 linters:177 linters:
173 - revive178 - revive
179+ # CEF handler/stub methods must keep receivers for interface compliance
180+ - path: internal/infrastructure/cef/
181+ text: "unused-receiver"
182+ linters:
183+ - revive
174 184
175 run:185 run:
176 timeout: 20m186 timeout: 20m
M .mockery.yaml +2 -1
@@ -35,7 +35,8 @@ packages:
35 Engine: {}35 Engine: {}
36 ContentInjector: {}36 ContentInjector: {}
37 SchemeHandler: {}37 SchemeHandler: {}
38- MessageRouter: {}38+ WebUIMessageHandler: {}
39+ WebUIHandlerRouter: {}
39 SettingsApplier: {}40 SettingsApplier: {}
40 FilterApplier: {}41 FilterApplier: {}
41 FaviconDatabase: {}42 FaviconDatabase: {}
M Makefile +12 -2
@@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
24 # Linker flags24 # Linker flags
25 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"25 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
26 26
27+# Disable optimizations and inlining for ALL packages. purego callbacks
28+# create mixed Go/C stack frames; the Go runtime (stack growth, GC,
29+# scheduler) cannot reliably traverse optimised frames across these
30+# boundaries. -N prevents register-only variables and stack slot reuse
31+# that corrupt the runtime's stack walker. -l prevents inlining across
32+# package boundaries. Both are required — -l alone is insufficient.
33+GCFLAGS=-gcflags 'all=-N -l'
34+
27 # Default target35 # Default target
28 help: ## Show this help message36 help: ## Show this help message
29 @echo "Available targets:"37 @echo "Available targets:"
@@ -33,7 +41,8 @@ help: ## Show this help message
33 build: build-frontend ## Build the application (pure Go, no CGO)41 build: build-frontend ## Build the application (pure Go, no CGO)
34 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."42 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
35 @mkdir -p $(DIST_DIR)43 @mkdir -p $(DIST_DIR)
36- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)44+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
37 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"46 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
38 47
39 build-frontend: ## Build homepage and error pages48 build-frontend: ## Build homepage and error pages
@@ -44,7 +53,8 @@ build-frontend: ## Build homepage and error pages
44 build-quick: ## Build without frontend (faster for backend development)53 build-quick: ## Build without frontend (faster for backend development)
45 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."54 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
46 @mkdir -p $(DIST_DIR)55 @mkdir -p $(DIST_DIR)
47- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)56+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
57+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
48 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"58 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
49 59
50 # Development targets60 # Development targets
A cef-helper +0 -0 binary file: no text diff

binary file: no text diff

A cmd/cef-helper/main.go +32 -0
@@ -0,0 +1,32 @@
1+// cef-helper is a minimal subprocess binary for CEF.
2+//
3+// CEF spawns renderer, GPU, and utility subprocesses by re-executing a binary
4+// with --type=renderer/gpu-process/utility. In Go, the main dumber binary has
5+// heavy initialization (config, DB, GTK) that corrupts process state before
6+// CEF gets control. This dedicated helper binary calls cef_execute_process as
7+// the very first thing and exits, avoiding that problem.
8+//
9+// Usage: set Settings.BrowserSubprocessPath to the path of this binary.
10+package main
11+
12+import (
13+ "os"
14+ "runtime"
15+
16+ cef "github.com/bnema/purego-cef/cef"
17+)
18+
19+func main() {
20+ // Pin to the main OS thread — CEF requires it.
21+ runtime.LockOSThread()
22+
23+ // cef_execute_process will handle the subprocess lifecycle and call
24+ // os.Exit internally if this is a valid CEF subprocess invocation.
25+ // If it returns (code < 0), this is not a subprocess — shouldn't
26+ // happen in normal operation since CEF only launches this binary
27+ // with --type= flags.
28+ cef.MaybeExitSubprocess()
29+
30+ // If we get here, something went wrong.
31+ os.Exit(1)
32+}
M cmd/dumber/main.go +75 -12
@@ -35,6 +35,7 @@ import (
35 "github.com/bnema/dumber/internal/ui/adapter"35 "github.com/bnema/dumber/internal/ui/adapter"
36 "github.com/bnema/dumber/internal/ui/component"36 "github.com/bnema/dumber/internal/ui/component"
37 "github.com/bnema/dumber/internal/ui/theme"37 "github.com/bnema/dumber/internal/ui/theme"
38+ cef "github.com/bnema/purego-cef/cef"
38 "github.com/bnema/puregotk/v4/gtk"39 "github.com/bnema/puregotk/v4/gtk"
39 "github.com/rs/zerolog"40 "github.com/rs/zerolog"
40 )41 )
@@ -88,8 +89,18 @@ func launchModeFromArgs(args []string) (launchMode, string) {
88 func main() {89 func main() {
89 enableCrashForensics()90 enableCrashForensics()
90 91
91- mode, browseURL := launchModeFromArgs(os.Args)92+ // CEF subprocess handling: when CEF re-launches the binary with
93+ // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
94+ // else (Cobra, config, arg stripping). We detect subprocesses by the
95+ // presence of --type= in os.Args rather than checking the engine env
96+ // var, because subprocesses may not inherit the environment and the
97+ // engine can also be selected via config.
98+ if isCEFSubprocess() {
99+ cef.MaybeExitSubprocess()
100+ os.Exit(1)
101+ }
92 102
103+ mode, browseURL := launchModeFromArgs(os.Args)
93 // Run GUI mode for browse command104 // Run GUI mode for browse command
94 if mode == launchModeBrowse {105 if mode == launchModeBrowse {
95 initialURL = browseURL106 initialURL = browseURL
@@ -136,6 +147,11 @@ func runGUI() int {
136 }147 }
137 timer.MarkDuration("parallel_phase", initResult.Duration)148 timer.MarkDuration("parallel_phase", initResult.Duration)
138 149
150+ preInitializeAdwaitaForCEF(cfg, initResult, func() {
151+ logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")
152+ ui.EnsureAdwaitaInitialized()
153+ })
154+
139 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore155 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
140 156
141 engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)157 engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
@@ -194,6 +210,8 @@ func runStandaloneOmnibox() int {
194 return 1210 return 1
195 }211 }
196 212
213+ preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized)
214+
197 engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)215 engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
198 if err != nil {216 if err != nil {
199 logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")217 logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
@@ -204,7 +222,7 @@ func runStandaloneOmnibox() int {
204 }222 }
205 223
206 useCases := createUseCases(repos, cfg)224 useCases := createUseCases(repos, cfg)
207- uiDeps := buildUIDependencies(225+ uiDeps, err := buildUIDependencies(
208 ctx,226 ctx,
209 cfg,227 cfg,
210 initResult.ThemeManager,228 initResult.ThemeManager,
@@ -217,11 +235,29 @@ func runStandaloneOmnibox() int {
217 "",235 "",
218 nil,236 nil,
219 )237 )
238+ if err != nil {
239+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies")
240+ return 1
241+ }
220 runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())242 runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
221 243
222 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)244 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
223 }245 }
224 246
247+func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
248+ if cfg == nil || initResult == nil || initAdwaita == nil {
249+ return
250+ }
251+ if cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
252+ return
253+ }
254+
255+ initAdwaita()
256+ if initResult.AdwaitaDetector != nil {
257+ initResult.AdwaitaDetector.MarkAvailable()
258+ }
259+}
260+
225 func maybeReexecStandaloneOmniboxWithLayerShell() {261 func maybeReexecStandaloneOmniboxWithLayerShell() {
226 env := bootstrap.CurrentEnvMap()262 env := bootstrap.CurrentEnvMap()
227 if !bootstrap.ShouldPreloadLayerShell(env) {263 if !bootstrap.ShouldPreloadLayerShell(env) {
@@ -295,10 +331,10 @@ func initBrowserSession(
295 bootstrapLog.Fatal().Err(err).Msg("failed to start session")331 bootstrapLog.Fatal().Err(err).Msg("failed to start session")
296 }332 }
297 cleanup := func() {333 cleanup := func() {
334+ _ = browserSession.End(sessionCtx)
298 if browserSession.LogCleanup != nil {335 if browserSession.LogCleanup != nil {
299 browserSession.LogCleanup()336 browserSession.LogCleanup()
300 }337 }
301- _ = browserSession.End(sessionCtx)
302 }338 }
303 return sessionCtx, browserSession, cleanup339 return sessionCtx, browserSession, cleanup
304 }340 }
@@ -319,11 +355,14 @@ func buildAndConfigureApp(
319 idleInhibitor port.IdleInhibitor,355 idleInhibitor port.IdleInhibitor,
320 browserSession *bootstrap.BrowserSession,356 browserSession *bootstrap.BrowserSession,
321 ) (*ui.App, error) {357 ) (*ui.App, error) {
322- uiDeps := buildUIDependencies(358+ uiDeps, err := buildUIDependencies(
323 ctx, cfg, initResult.ThemeManager,359 ctx, cfg, initResult.ThemeManager,
324 initResult.ColorResolver, initResult.AdwaitaDetector,360 initResult.ColorResolver, initResult.AdwaitaDetector,
325- engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),361+ engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
326 )362 )
363+ if err != nil {
364+ return nil, err
365+ }
327 configureDeferredInit(uiDeps, cfg, browserSession)366 configureDeferredInit(uiDeps, cfg, browserSession)
328 return ui.New(uiDeps)367 return ui.New(uiDeps)
329 }368 }
@@ -424,7 +463,7 @@ func configureDeferredInit(
424 logger.Error().Err(persistErr).Msg("deferred session persistence failed")463 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
425 } else {464 } else {
426 if uiDeps.OnCrashReportsDetected != nil {465 if uiDeps.OnCrashReportsDetected != nil {
427- if reports := session.UnexpectedCloseReports(); len(reports) > 0 {466+ if reports := session.CrashReports(); len(reports) > 0 {
428 uiDeps.OnCrashReportsDetected(reports)467 uiDeps.OnCrashReportsDetected(reports)
429 }468 }
430 }469 }
@@ -502,13 +541,20 @@ func handleAutoRestore(
502 541
503 func setupSignalHandler(ctx context.Context, app *ui.App) {542 func setupSignalHandler(ctx context.Context, app *ui.App) {
504 log := logging.FromContext(ctx)543 log := logging.FromContext(ctx)
505- sigCh := make(chan os.Signal, 1)544+ sigCh := make(chan os.Signal, 2)
506 signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)545 signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
507 go func() {546 go func() {
508 sig := <-sigCh547 sig := <-sigCh
509- signal.Stop(sigCh)548+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting gracefully")
510- log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
511 app.Quit()549 app.Quit()
550+
551+ // Second signal: force exit. Keep listening so a second Ctrl+C
552+ // doesn't go to the default handler (immediate process kill)
553+ // before GTK shutdown + CEF cleanup can finish.
554+ sig = <-sigCh
555+ log.Warn().Str("signal", sig.String()).Msg("received second interrupt, forcing exit")
556+ signal.Stop(sigCh)
557+ os.Exit(1)
512 }()558 }()
513 }559 }
514 560
@@ -606,7 +652,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
606 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),652 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
607 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),653 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
608 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),654 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
609- lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),655+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState),
610 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),656 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
611 applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),657 applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
612 clipboard: clipboardAdapter,658 clipboard: clipboardAdapter,
@@ -614,6 +660,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
614 }660 }
615 }661 }
616 662
663+// isCEFSubprocess returns true if os.Args contains a --type= flag, indicating
664+// this process was spawned by CEF as a renderer, GPU, or utility subprocess.
665+func isCEFSubprocess() bool {
666+ for _, arg := range os.Args {
667+ if strings.HasPrefix(arg, "--type=") {
668+ return true
669+ }
670+ }
671+ return false
672+}
673+
617 func buildUIDependencies(674 func buildUIDependencies(
618 ctx context.Context,675 ctx context.Context,
619 cfg *config.Config,676 cfg *config.Config,
@@ -626,12 +683,17 @@ func buildUIDependencies(
626 idleInhibitor port.IdleInhibitor,683 idleInhibitor port.IdleInhibitor,
627 currentSessionID entity.SessionID,684 currentSessionID entity.SessionID,
628 startupCrashReports []string,685 startupCrashReports []string,
629-) *ui.Dependencies {686+) (*ui.Dependencies, error) {
630 var filterManager port.FilterManager687 var filterManager port.FilterManager
631 if fmp, ok := engine.(port.FilterManagerProvider); ok {688 if fmp, ok := engine.(port.FilterManagerProvider); ok {
632 filterManager = fmp.InternalFilterManager()689 filterManager = fmp.InternalFilterManager()
633 }690 }
634 691
692+ handlerDeps, err := bootstrap.BuildHandlerDeps(ctx)
693+ if err != nil {
694+ return nil, fmt.Errorf("failed to build handler deps: %w", err)
695+ }
696+
635 focusProvider := textinput.NewFocusProvider()697 focusProvider := textinput.NewFocusProvider()
636 698
637 uiDeps := &ui.Dependencies{699 uiDeps := &ui.Dependencies{
@@ -699,7 +761,8 @@ func buildUIDependencies(
699 LaunchExternalURL: desktop.LaunchExternalURL,761 LaunchExternalURL: desktop.LaunchExternalURL,
700 LaunchBrowserURL: desktop.LaunchBrowserURL,762 LaunchBrowserURL: desktop.LaunchBrowserURL,
701 ConfigMigrator: config.NewMigrator(),763 ConfigMigrator: config.NewMigrator(),
764+ HandlerDeps: *handlerDeps,
702 }765 }
703 766
704- return uiDeps767+ return uiDeps, nil
705 }768 }
M cmd/dumber/main_test.go +44 -0
@@ -3,6 +3,10 @@ package main
3 import (3 import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/dumber/internal/bootstrap"
8+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
6 )10 )
7 11
8 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {12 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
@@ -87,3 +91,43 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
87 t.Fatalf("expected error %v, got %v", wantErr, err)91 t.Fatalf("expected error %v, got %v", wantErr, err)
88 }92 }
89 }93 }
94+
95+func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {
96+ initResult := &bootstrap.ParallelInitResult{
97+ AdwaitaDetector: colorscheme.NewAdwaitaDetector(),
98+ }
99+ cfg := &config.Config{}
100+ cfg.Engine.Type = config.EngineTypeCEF
101+
102+ called := false
103+ preInitializeAdwaitaForCEF(cfg, initResult, func() {
104+ called = true
105+ })
106+
107+ if !called {
108+ t.Fatal("expected libadwaita initialization for CEF")
109+ }
110+ if !initResult.AdwaitaDetector.Available() {
111+ t.Fatal("expected adwaita detector to be marked available")
112+ }
113+}
114+
115+func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
116+ initResult := &bootstrap.ParallelInitResult{
117+ AdwaitaDetector: colorscheme.NewAdwaitaDetector(),
118+ }
119+ cfg := &config.Config{}
120+ cfg.Engine.Type = config.EngineTypeWebKit
121+
122+ called := false
123+ preInitializeAdwaitaForCEF(cfg, initResult, func() {
124+ called = true
125+ })
126+
127+ if called {
128+ t.Fatal("expected libadwaita initialization to be skipped for non-CEF")
129+ }
130+ if initResult.AdwaitaDetector.Available() {
131+ t.Fatal("expected adwaita detector to remain unavailable")
132+ }
133+}
M go.mod +7 -2
@@ -3,6 +3,9 @@ module github.com/bnema/dumber
3 go 1.26.13 go 1.26.1
4 4
5 require (5 require (
6+ github.com/bnema/purego-cef v0.8.1
7+ github.com/bnema/purego-ffmpeg v0.3.0
8+ github.com/bnema/purego-pipewire v0.1.1
6 github.com/bnema/purego-sqlite v0.1.19 github.com/bnema/purego-sqlite v0.1.1
7 github.com/bnema/puregotk v0.3.010 github.com/bnema/puregotk v0.3.0
8 github.com/bnema/puregotk-webkit v0.0.1611 github.com/bnema/puregotk-webkit v0.0.16
@@ -12,6 +15,7 @@ require (
12 github.com/ebitengine/purego v0.10.015 github.com/ebitengine/purego v0.10.0
13 github.com/fsnotify/fsnotify v1.9.016 github.com/fsnotify/fsnotify v1.9.0
14 github.com/godbus/dbus/v5 v5.2.217 github.com/godbus/dbus/v5 v5.2.2
18+ github.com/google/uuid v1.6.0
15 github.com/pelletier/go-toml/v2 v2.3.019 github.com/pelletier/go-toml/v2 v2.3.0
16 github.com/pressly/goose/v3 v3.27.020 github.com/pressly/goose/v3 v3.27.0
17 github.com/rs/zerolog v1.34.021 github.com/rs/zerolog v1.34.0
@@ -23,8 +27,6 @@ require (
23 gopkg.in/yaml.v3 v3.0.127 gopkg.in/yaml.v3 v3.0.1
24 )28 )
25 29
26-replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
27-
28 require (30 require (
29 github.com/atotto/clipboard v0.1.4 // indirect31 github.com/atotto/clipboard v0.1.4 // indirect
30 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect32 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -66,4 +68,7 @@ require (
66 golang.org/x/sync v0.20.0 // indirect68 golang.org/x/sync v0.20.0 // indirect
67 golang.org/x/text v0.35.0 // indirect69 golang.org/x/text v0.35.0 // indirect
68 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect70 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
71+ modernc.org/sqlite v1.47.0 // indirect
69 )72 )
73+
74+replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
M go.sum +10 -6 generated file: collapsed
A internal/application/port/audio_output.go +34 -0
@@ -0,0 +1,34 @@
1+// Package port defines application-layer interfaces for external capabilities.
2+// This file defines the Audio Output port for CEF audio streaming on Linux.
3+package port
4+
5+import "context"
6+
7+// AudioStreamFormat represents the audio stream format configuration.
8+// This is a value object that describes how audio data should be streamed.
9+type AudioStreamFormat struct {
10+ // SampleRate is the audio sample rate in Hz (e.g., 48000, 44100).
11+ SampleRate int
12+ // ChannelCount is the number of audio channels (e.g., 1 for mono, 2 for stereo).
13+ ChannelCount int
14+ // FramesPerBuffer is the number of frames per buffer (e.g., 512, 1024).
15+ FramesPerBuffer int
16+}
17+
18+// AudioOutputStream represents an active audio output stream.
19+// Implementations handle the low-level audio playback.
20+type AudioOutputStream interface {
21+ // Write sends audio samples to the output device.
22+ // Samples are provided as a 2D slice: [channel][frame]float32.
23+ // This matches CEF's planar float32 audio format.
24+ Write(samples [][]float32) error
25+ // Close releases the audio stream and associated resources.
26+ Close() error
27+}
28+
29+// AudioOutputFactory creates audio output streams.
30+// This is the primary interface for the audio output port.
31+type AudioOutputFactory interface {
32+ // NewStream creates a new audio output stream with the given format.
33+ NewStream(ctx context.Context, format AudioStreamFormat) (AudioOutputStream, error)
34+}
A internal/application/port/audio_output_test.go +51 -0
@@ -0,0 +1,51 @@
1+package port_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+// TestAudioOutputPortContract verifies that the Audio Output port contract
12+// matches the approved design exactly.
13+func TestAudioOutputPortContract(t *testing.T) {
14+ t.Run("AudioStreamFormat is a struct with correct fields", func(t *testing.T) {
15+ format := port.AudioStreamFormat{
16+ SampleRate: 48000,
17+ ChannelCount: 2,
18+ FramesPerBuffer: 512,
19+ }
20+
21+ assert.Equal(t, 48000, format.SampleRate)
22+ assert.Equal(t, 2, format.ChannelCount)
23+ assert.Equal(t, 512, format.FramesPerBuffer)
24+ })
25+
26+ t.Run("AudioOutputStream interface has Write with [][]float32", func(_ *testing.T) {
27+ var _ port.AudioOutputStream = (*mockAudioOutputStream)(nil)
28+ })
29+
30+ t.Run("AudioOutputFactory has NewStream with context and format", func(_ *testing.T) {
31+ var _ port.AudioOutputFactory = (*mockAudioOutputFactory)(nil)
32+ })
33+}
34+
35+// mockAudioOutputStream is a test mock for the AudioOutputStream interface
36+type mockAudioOutputStream struct{}
37+
38+func (*mockAudioOutputStream) Write(_ [][]float32) error {
39+ return nil
40+}
41+
42+func (*mockAudioOutputStream) Close() error {
43+ return nil
44+}
45+
46+// mockAudioOutputFactory is a test mock for the AudioOutputFactory interface
47+type mockAudioOutputFactory struct{}
48+
49+func (*mockAudioOutputFactory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) {
50+ return &mockAudioOutputStream{}, nil
51+}
M internal/application/port/engine.go +36 -14
@@ -3,7 +3,10 @@
3 // remain independent of specific implementations (WebKit, GTK, etc.).3 // remain independent of specific implementations (WebKit, GTK, etc.).
4 package port4 package port
5 5
6-import "context"6+import (
7+ "context"
8+ "encoding/json"
9+)
7 10
8 // CookiePolicy controls cookie acceptance behavior for the engine's network session.11 // CookiePolicy controls cookie acceptance behavior for the engine's network session.
9 type CookiePolicy string12 type CookiePolicy string
@@ -157,6 +160,37 @@ type EngineSettingsUpdate struct {
157 Raw any //nolint:iface // intentional: see type comment above160 Raw any //nolint:iface // intentional: see type comment above
158 }161 }
159 162
163+// WebUIMessageHandler handles a decoded message payload from the JS bridge.
164+// Used by both WebKit and CEF message routers via the shared handlers package.
165+type WebUIMessageHandler interface {
166+ Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)
167+}
168+
169+// WebUIMessageHandlerFunc adapts a function to the WebUIMessageHandler interface.
170+type WebUIMessageHandlerFunc func(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)
171+
172+// Handle calls f(ctx, webviewID, payload).
173+func (f WebUIMessageHandlerFunc) Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) {
174+ return f(ctx, webviewID, payload)
175+}
176+
177+// WebUIHandlerRouter registers message handlers for the JS↔Go bridge.
178+// Both WebKit's MessageRouter and CEF's MessageRouter implement this interface,
179+// allowing shared handler registration code.
180+type WebUIHandlerRouter interface {
181+ RegisterHandler(msgType string, handler WebUIMessageHandler) error
182+ RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler WebUIMessageHandler) error
183+}
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+
160 // HandlerDependencies holds use cases needed by WebUI message handlers.194 // HandlerDependencies holds use cases needed by WebUI message handlers.
161 type HandlerDependencies struct {195 type HandlerDependencies struct {
162 HistoryUC HomepageHistory196 HistoryUC HomepageHistory
@@ -164,6 +198,7 @@ type HandlerDependencies struct {
164 Clipboard Clipboard198 Clipboard Clipboard
165 AutoCopyConfig AutoCopyConfig199 AutoCopyConfig AutoCopyConfig
166 OnClipboardCopied func(textLen int)200 OnClipboardCopied func(textLen int)
201+ HandlerDeps
167 }202 }
168 203
169 // SchemeHandler defines the port interface for registering custom URI schemes.204 // SchemeHandler defines the port interface for registering custom URI schemes.
@@ -176,19 +211,6 @@ type SchemeHandler interface {
176 RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error))211 RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error))
177 }212 }
178 213
179-// MessageRouter defines the port interface for bidirectional JS-to-Go messaging.
180-// It allows JavaScript running in a WebView to invoke named Go handlers,
181-// and allows Go code to post messages back to a specific WebView.
182-type MessageRouter interface {
183- // RegisterHandler registers a named message handler callable from JavaScript.
184- // The handler receives a JSON-encoded message string and returns a
185- // JSON-encoded response string.
186- RegisterHandler(name string, handler func(message string) (string, error))
187-
188- // PostMessage sends a message to the JavaScript context of the given WebView.
189- PostMessage(webviewID WebViewID, message string) error
190-}
191-
192 // SettingsApplier defines the port interface for applying browser settings to WebViews.214 // SettingsApplier defines the port interface for applying browser settings to WebViews.
193 // Implementations apply engine-specific settings (security, features, etc.) uniformly215 // Implementations apply engine-specific settings (security, features, etc.) uniformly
194 // across a set of WebView instances.216 // across a set of WebView instances.
M internal/application/port/favicon_service.go +1 -1
@@ -4,7 +4,7 @@ import "context"
4 4
5 // FaviconService provides favicon retrieval and caching.5 // FaviconService provides favicon retrieval and caching.
6 type FaviconService interface {6 type FaviconService interface {
7- GetCached(domain string) ([]byte, bool)7+ GetCached(ctx context.Context, domain string) ([]byte, bool)
8 Get(ctx context.Context, domain string) ([]byte, error)8 Get(ctx context.Context, domain string) ([]byte, error)
9 DiskPathPNG(domain string) string9 DiskPathPNG(domain string) string
10 HasPNGOnDisk(domain string) bool10 HasPNGOnDisk(domain string) bool
M internal/application/port/handler_deps.go +15 -0
@@ -30,3 +30,18 @@ type DownloadPrepareOutput struct {
30 type AutoCopyConfig interface {30 type AutoCopyConfig interface {
31 IsAutoCopyEnabled() bool31 IsAutoCopyEnabled() bool
32 }32 }
33+
34+// HandlerDeps holds handler dependencies built at bootstrap time.
35+// Lives in the port layer so both bootstrap and ui can depend on it
36+// without creating a reverse dependency.
37+//
38+// This is an intentional exception to the interfaces-only port layer guideline.
39+// A struct is used here to avoid reverse dependencies between the bootstrap and
40+// UI layers, which both need to reference this shared dependency container.
41+type HandlerDeps struct {
42+ SaveConfig func(context.Context, WebUIConfig) error
43+ KeybindingsGetter KeybindingsGetter
44+ KeybindingSetter KeybindingSetter
45+ KeybindingResetter KeybindingResetter
46+ AllKeybindingsResetter AllKeybindingsResetter
47+}
M internal/application/port/mocks/mocks.go +222 -85
@@ -7,6 +7,7 @@ package mocks
7 import (7 import (
8 "context"8 "context"
9 "database/sql"9 "database/sql"
10+ "encoding/json"
10 "time"11 "time"
11 12
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
@@ -3395,13 +3396,13 @@ func (_c *MockEngine_UpdateSettings_Call) RunAndReturn(run func(ctx context.Cont
3395 return _c3396 return _c
3396 }3397 }
3397 3398
3398-// NewMockSchemeHandler creates a new instance of MockSchemeHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3399+// NewMockWebUIMessageHandler creates a new instance of MockWebUIMessageHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3399 // The first argument is typically a *testing.T value.3400 // The first argument is typically a *testing.T value.
3400-func NewMockSchemeHandler(t interface {3401+func NewMockWebUIMessageHandler(t interface {
3401 mock.TestingT3402 mock.TestingT
3402 Cleanup(func())3403 Cleanup(func())
3403-}) *MockSchemeHandler {3404+}) *MockWebUIMessageHandler {
3404- mock := &MockSchemeHandler{}3405+ mock := &MockWebUIMessageHandler{}
3405 mock.Mock.Test(t)3406 mock.Mock.Test(t)
3406 3407
3407 t.Cleanup(func() { mock.AssertExpectations(t) })3408 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -3409,72 +3410,100 @@ func NewMockSchemeHandler(t interface {
3409 return mock3410 return mock
3410 }3411 }
3411 3412
3412-// MockSchemeHandler is an autogenerated mock type for the SchemeHandler type3413+// MockWebUIMessageHandler is an autogenerated mock type for the WebUIMessageHandler type
3413-type MockSchemeHandler struct {3414+type MockWebUIMessageHandler struct {
3414 mock.Mock3415 mock.Mock
3415 }3416 }
3416 3417
3417-type MockSchemeHandler_Expecter struct {3418+type MockWebUIMessageHandler_Expecter struct {
3418 mock *mock.Mock3419 mock *mock.Mock
3419 }3420 }
3420 3421
3421-func (_m *MockSchemeHandler) EXPECT() *MockSchemeHandler_Expecter {3422+func (_m *MockWebUIMessageHandler) EXPECT() *MockWebUIMessageHandler_Expecter {
3422- return &MockSchemeHandler_Expecter{mock: &_m.Mock}3423+ return &MockWebUIMessageHandler_Expecter{mock: &_m.Mock}
3423 }3424 }
3424 3425
3425-// RegisterScheme provides a mock function for the type MockSchemeHandler3426+// Handle provides a mock function for the type MockWebUIMessageHandler
3426-func (_mock *MockSchemeHandler) RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error)) {3427+func (_mock *MockWebUIMessageHandler) Handle(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) {
3427- _mock.Called(scheme, handler)3428+ ret := _mock.Called(ctx, webviewID, payload)
3428- return3429+
3430+ if len(ret) == 0 {
3431+ panic("no return value specified for Handle")
3432+ }
3433+
3434+ var r0 any
3435+ var r1 error
3436+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebViewID, json.RawMessage) (any, error)); ok {
3437+ return returnFunc(ctx, webviewID, payload)
3438+ }
3439+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebViewID, json.RawMessage) any); ok {
3440+ r0 = returnFunc(ctx, webviewID, payload)
3441+ } else {
3442+ if ret.Get(0) != nil {
3443+ r0 = ret.Get(0).(any)
3444+ }
3445+ }
3446+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.WebViewID, json.RawMessage) error); ok {
3447+ r1 = returnFunc(ctx, webviewID, payload)
3448+ } else {
3449+ r1 = ret.Error(1)
3450+ }
3451+ return r0, r1
3429 }3452 }
3430 3453
3431-// MockSchemeHandler_RegisterScheme_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterScheme'3454+// MockWebUIMessageHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle'
3432-type MockSchemeHandler_RegisterScheme_Call struct {3455+type MockWebUIMessageHandler_Handle_Call struct {
3433 *mock.Call3456 *mock.Call
3434 }3457 }
3435 3458
3436-// RegisterScheme is a helper method to define mock.On call3459+// Handle is a helper method to define mock.On call
3437-// - scheme string3460+// - ctx context.Context
3438-// - handler func(uri string) ([]byte, string, error)3461+// - webviewID port.WebViewID
3439-func (_e *MockSchemeHandler_Expecter) RegisterScheme(scheme interface{}, handler interface{}) *MockSchemeHandler_RegisterScheme_Call {3462+// - payload json.RawMessage
3440- return &MockSchemeHandler_RegisterScheme_Call{Call: _e.mock.On("RegisterScheme", scheme, handler)}3463+func (_e *MockWebUIMessageHandler_Expecter) Handle(ctx interface{}, webviewID interface{}, payload interface{}) *MockWebUIMessageHandler_Handle_Call {
3464+ return &MockWebUIMessageHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, webviewID, payload)}
3441 }3465 }
3442 3466
3443-func (_c *MockSchemeHandler_RegisterScheme_Call) Run(run func(scheme string, handler func(uri string) ([]byte, string, error))) *MockSchemeHandler_RegisterScheme_Call {3467+func (_c *MockWebUIMessageHandler_Handle_Call) Run(run func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage)) *MockWebUIMessageHandler_Handle_Call {
3444 _c.Call.Run(func(args mock.Arguments) {3468 _c.Call.Run(func(args mock.Arguments) {
3445- var arg0 string3469+ var arg0 context.Context
3446 if args[0] != nil {3470 if args[0] != nil {
3447- arg0 = args[0].(string)3471+ arg0 = args[0].(context.Context)
3448 }3472 }
3449- var arg1 func(uri string) ([]byte, string, error)3473+ var arg1 port.WebViewID
3450 if args[1] != nil {3474 if args[1] != nil {
3451- arg1 = args[1].(func(uri string) ([]byte, string, error))3475+ arg1 = args[1].(port.WebViewID)
3476+ }
3477+ var arg2 json.RawMessage
3478+ if args[2] != nil {
3479+ arg2 = args[2].(json.RawMessage)
3452 }3480 }
3453 run(3481 run(
3454 arg0,3482 arg0,
3455 arg1,3483 arg1,
3484+ arg2,
3456 )3485 )
3457 })3486 })
3458 return _c3487 return _c
3459 }3488 }
3460 3489
3461-func (_c *MockSchemeHandler_RegisterScheme_Call) Return() *MockSchemeHandler_RegisterScheme_Call {3490+func (_c *MockWebUIMessageHandler_Handle_Call) Return(v any, err error) *MockWebUIMessageHandler_Handle_Call {
3462- _c.Call.Return()3491+ _c.Call.Return(v, err)
3463 return _c3492 return _c
3464 }3493 }
3465 3494
3466-func (_c *MockSchemeHandler_RegisterScheme_Call) RunAndReturn(run func(scheme string, handler func(uri string) ([]byte, string, error))) *MockSchemeHandler_RegisterScheme_Call {3495+func (_c *MockWebUIMessageHandler_Handle_Call) RunAndReturn(run func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error)) *MockWebUIMessageHandler_Handle_Call {
3467- _c.Run(run)3496+ _c.Call.Return(run)
3468 return _c3497 return _c
3469 }3498 }
3470 3499
3471-// NewMockMessageRouter creates a new instance of MockMessageRouter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3500+// NewMockWebUIHandlerRouter creates a new instance of MockWebUIHandlerRouter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3472 // The first argument is typically a *testing.T value.3501 // The first argument is typically a *testing.T value.
3473-func NewMockMessageRouter(t interface {3502+func NewMockWebUIHandlerRouter(t interface {
3474 mock.TestingT3503 mock.TestingT
3475 Cleanup(func())3504 Cleanup(func())
3476-}) *MockMessageRouter {3505+}) *MockWebUIHandlerRouter {
3477- mock := &MockMessageRouter{}3506+ mock := &MockWebUIHandlerRouter{}
3478 mock.Mock.Test(t)3507 mock.Mock.Test(t)
3479 3508
3480 t.Cleanup(func() { mock.AssertExpectations(t) })3509 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -3482,103 +3511,205 @@ func NewMockMessageRouter(t interface {
3482 return mock3511 return mock
3483 }3512 }
3484 3513
3485-// MockMessageRouter is an autogenerated mock type for the MessageRouter type3514+// MockWebUIHandlerRouter is an autogenerated mock type for the WebUIHandlerRouter type
3486-type MockMessageRouter struct {3515+type MockWebUIHandlerRouter struct {
3487 mock.Mock3516 mock.Mock
3488 }3517 }
3489 3518
3490-type MockMessageRouter_Expecter struct {3519+type MockWebUIHandlerRouter_Expecter struct {
3491 mock *mock.Mock3520 mock *mock.Mock
3492 }3521 }
3493 3522
3494-func (_m *MockMessageRouter) EXPECT() *MockMessageRouter_Expecter {3523+func (_m *MockWebUIHandlerRouter) EXPECT() *MockWebUIHandlerRouter_Expecter {
3495- return &MockMessageRouter_Expecter{mock: &_m.Mock}3524+ return &MockWebUIHandlerRouter_Expecter{mock: &_m.Mock}
3496 }3525 }
3497 3526
3498-// PostMessage provides a mock function for the type MockMessageRouter3527+// RegisterHandler provides a mock function for the type MockWebUIHandlerRouter
3499-func (_mock *MockMessageRouter) PostMessage(webviewID port.WebViewID, message string) error {3528+func (_mock *MockWebUIHandlerRouter) RegisterHandler(msgType string, handler port.WebUIMessageHandler) error {
3500- ret := _mock.Called(webviewID, message)3529+ ret := _mock.Called(msgType, handler)
3501 3530
3502 if len(ret) == 0 {3531 if len(ret) == 0 {
3503- panic("no return value specified for PostMessage")3532+ panic("no return value specified for RegisterHandler")
3504 }3533 }
3505 3534
3506 var r0 error3535 var r0 error
3507- if returnFunc, ok := ret.Get(0).(func(port.WebViewID, string) error); ok {3536+ if returnFunc, ok := ret.Get(0).(func(string, port.WebUIMessageHandler) error); ok {
3508- r0 = returnFunc(webviewID, message)3537+ r0 = returnFunc(msgType, handler)
3509 } else {3538 } else {
3510 r0 = ret.Error(0)3539 r0 = ret.Error(0)
3511 }3540 }
3512 return r03541 return r0
3513 }3542 }
3514 3543
3515-// MockMessageRouter_PostMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PostMessage'3544+// MockWebUIHandlerRouter_RegisterHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterHandler'
3516-type MockMessageRouter_PostMessage_Call struct {3545+type MockWebUIHandlerRouter_RegisterHandler_Call struct {
3517 *mock.Call3546 *mock.Call
3518 }3547 }
3519 3548
3520-// PostMessage is a helper method to define mock.On call3549+// RegisterHandler is a helper method to define mock.On call
3521-// - webviewID port.WebViewID3550+// - msgType string
3522-// - message string3551+// - handler port.WebUIMessageHandler
3523-func (_e *MockMessageRouter_Expecter) PostMessage(webviewID interface{}, message interface{}) *MockMessageRouter_PostMessage_Call {3552+func (_e *MockWebUIHandlerRouter_Expecter) RegisterHandler(msgType interface{}, handler interface{}) *MockWebUIHandlerRouter_RegisterHandler_Call {
3524- return &MockMessageRouter_PostMessage_Call{Call: _e.mock.On("PostMessage", webviewID, message)}3553+ return &MockWebUIHandlerRouter_RegisterHandler_Call{Call: _e.mock.On("RegisterHandler", msgType, handler)}
3554+}
3555+
3556+func (_c *MockWebUIHandlerRouter_RegisterHandler_Call) Run(run func(msgType string, handler port.WebUIMessageHandler)) *MockWebUIHandlerRouter_RegisterHandler_Call {
3557+ _c.Call.Run(func(args mock.Arguments) {
3558+ var arg0 string
3559+ if args[0] != nil {
3560+ arg0 = args[0].(string)
3561+ }
3562+ var arg1 port.WebUIMessageHandler
3563+ if args[1] != nil {
3564+ arg1 = args[1].(port.WebUIMessageHandler)
3565+ }
3566+ run(
3567+ arg0,
3568+ arg1,
3569+ )
3570+ })
3571+ return _c
3572+}
3573+
3574+func (_c *MockWebUIHandlerRouter_RegisterHandler_Call) Return(err error) *MockWebUIHandlerRouter_RegisterHandler_Call {
3575+ _c.Call.Return(err)
3576+ return _c
3577+}
3578+
3579+func (_c *MockWebUIHandlerRouter_RegisterHandler_Call) RunAndReturn(run func(msgType string, handler port.WebUIMessageHandler) error) *MockWebUIHandlerRouter_RegisterHandler_Call {
3580+ _c.Call.Return(run)
3581+ return _c
3582+}
3583+
3584+// RegisterHandlerWithCallbacks provides a mock function for the type MockWebUIHandlerRouter
3585+func (_mock *MockWebUIHandlerRouter) RegisterHandlerWithCallbacks(msgType string, callback string, errorCallback string, worldName string, handler port.WebUIMessageHandler) error {
3586+ ret := _mock.Called(msgType, callback, errorCallback, worldName, handler)
3587+
3588+ if len(ret) == 0 {
3589+ panic("no return value specified for RegisterHandlerWithCallbacks")
3590+ }
3591+
3592+ var r0 error
3593+ if returnFunc, ok := ret.Get(0).(func(string, string, string, string, port.WebUIMessageHandler) error); ok {
3594+ r0 = returnFunc(msgType, callback, errorCallback, worldName, handler)
3595+ } else {
3596+ r0 = ret.Error(0)
3597+ }
3598+ return r0
3599+}
3600+
3601+// MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterHandlerWithCallbacks'
3602+type MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call struct {
3603+ *mock.Call
3604+}
3605+
3606+// RegisterHandlerWithCallbacks is a helper method to define mock.On call
3607+// - msgType string
3608+// - callback string
3609+// - errorCallback string
3610+// - worldName string
3611+// - handler port.WebUIMessageHandler
3612+func (_e *MockWebUIHandlerRouter_Expecter) RegisterHandlerWithCallbacks(msgType interface{}, callback interface{}, errorCallback interface{}, worldName interface{}, handler interface{}) *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call {
3613+ return &MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call{Call: _e.mock.On("RegisterHandlerWithCallbacks", msgType, callback, errorCallback, worldName, handler)}
3525 }3614 }
3526 3615
3527-func (_c *MockMessageRouter_PostMessage_Call) Run(run func(webviewID port.WebViewID, message string)) *MockMessageRouter_PostMessage_Call {3616+func (_c *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call) Run(run func(msgType string, callback string, errorCallback string, worldName string, handler port.WebUIMessageHandler)) *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call {
3528 _c.Call.Run(func(args mock.Arguments) {3617 _c.Call.Run(func(args mock.Arguments) {
3529- var arg0 port.WebViewID3618+ var arg0 string
3530 if args[0] != nil {3619 if args[0] != nil {
3531- arg0 = args[0].(port.WebViewID)3620+ arg0 = args[0].(string)
3532 }3621 }
3533 var arg1 string3622 var arg1 string
3534 if args[1] != nil {3623 if args[1] != nil {
3535 arg1 = args[1].(string)3624 arg1 = args[1].(string)
3536 }3625 }
3626+ var arg2 string
3627+ if args[2] != nil {
3628+ arg2 = args[2].(string)
3629+ }
3630+ var arg3 string
3631+ if args[3] != nil {
3632+ arg3 = args[3].(string)
3633+ }
3634+ var arg4 port.WebUIMessageHandler
3635+ if args[4] != nil {
3636+ arg4 = args[4].(port.WebUIMessageHandler)
3637+ }
3537 run(3638 run(
3538 arg0,3639 arg0,
3539 arg1,3640 arg1,
3641+ arg2,
3642+ arg3,
3643+ arg4,
3540 )3644 )
3541 })3645 })
3542 return _c3646 return _c
3543 }3647 }
3544 3648
3545-func (_c *MockMessageRouter_PostMessage_Call) Return(err error) *MockMessageRouter_PostMessage_Call {3649+func (_c *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call) Return(err error) *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call {
3546 _c.Call.Return(err)3650 _c.Call.Return(err)
3547 return _c3651 return _c
3548 }3652 }
3549 3653
3550-func (_c *MockMessageRouter_PostMessage_Call) RunAndReturn(run func(webviewID port.WebViewID, message string) error) *MockMessageRouter_PostMessage_Call {3654+func (_c *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call) RunAndReturn(run func(msgType string, callback string, errorCallback string, worldName string, handler port.WebUIMessageHandler) error) *MockWebUIHandlerRouter_RegisterHandlerWithCallbacks_Call {
3551 _c.Call.Return(run)3655 _c.Call.Return(run)
3552 return _c3656 return _c
3553 }3657 }
3554 3658
3555-// RegisterHandler provides a mock function for the type MockMessageRouter3659+// NewMockSchemeHandler creates a new instance of MockSchemeHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3556-func (_mock *MockMessageRouter) RegisterHandler(name string, handler func(message string) (string, error)) {3660+// The first argument is typically a *testing.T value.
3557- _mock.Called(name, handler)3661+func NewMockSchemeHandler(t interface {
3662+ mock.TestingT
3663+ Cleanup(func())
3664+}) *MockSchemeHandler {
3665+ mock := &MockSchemeHandler{}
3666+ mock.Mock.Test(t)
3667+
3668+ t.Cleanup(func() { mock.AssertExpectations(t) })
3669+
3670+ return mock
3671+}
3672+
3673+// MockSchemeHandler is an autogenerated mock type for the SchemeHandler type
3674+type MockSchemeHandler struct {
3675+ mock.Mock
3676+}
3677+
3678+type MockSchemeHandler_Expecter struct {
3679+ mock *mock.Mock
3680+}
3681+
3682+func (_m *MockSchemeHandler) EXPECT() *MockSchemeHandler_Expecter {
3683+ return &MockSchemeHandler_Expecter{mock: &_m.Mock}
3684+}
3685+
3686+// RegisterScheme provides a mock function for the type MockSchemeHandler
3687+func (_mock *MockSchemeHandler) RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error)) {
3688+ _mock.Called(scheme, handler)
3558 return3689 return
3559 }3690 }
3560 3691
3561-// MockMessageRouter_RegisterHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterHandler'3692+// MockSchemeHandler_RegisterScheme_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterScheme'
3562-type MockMessageRouter_RegisterHandler_Call struct {3693+type MockSchemeHandler_RegisterScheme_Call struct {
3563 *mock.Call3694 *mock.Call
3564 }3695 }
3565 3696
3566-// RegisterHandler is a helper method to define mock.On call3697+// RegisterScheme is a helper method to define mock.On call
3567-// - name string3698+// - scheme string
3568-// - handler func(message string) (string, error)3699+// - handler func(uri string) ([]byte, string, error)
3569-func (_e *MockMessageRouter_Expecter) RegisterHandler(name interface{}, handler interface{}) *MockMessageRouter_RegisterHandler_Call {3700+func (_e *MockSchemeHandler_Expecter) RegisterScheme(scheme interface{}, handler interface{}) *MockSchemeHandler_RegisterScheme_Call {
3570- return &MockMessageRouter_RegisterHandler_Call{Call: _e.mock.On("RegisterHandler", name, handler)}3701+ return &MockSchemeHandler_RegisterScheme_Call{Call: _e.mock.On("RegisterScheme", scheme, handler)}
3571 }3702 }
3572 3703
3573-func (_c *MockMessageRouter_RegisterHandler_Call) Run(run func(name string, handler func(message string) (string, error))) *MockMessageRouter_RegisterHandler_Call {3704+func (_c *MockSchemeHandler_RegisterScheme_Call) Run(run func(scheme string, handler func(uri string) ([]byte, string, error))) *MockSchemeHandler_RegisterScheme_Call {
3574 _c.Call.Run(func(args mock.Arguments) {3705 _c.Call.Run(func(args mock.Arguments) {
3575 var arg0 string3706 var arg0 string
3576 if args[0] != nil {3707 if args[0] != nil {
3577 arg0 = args[0].(string)3708 arg0 = args[0].(string)
3578 }3709 }
3579- var arg1 func(message string) (string, error)3710+ var arg1 func(uri string) ([]byte, string, error)
3580 if args[1] != nil {3711 if args[1] != nil {
3581- arg1 = args[1].(func(message string) (string, error))3712+ arg1 = args[1].(func(uri string) ([]byte, string, error))
3582 }3713 }
3583 run(3714 run(
3584 arg0,3715 arg0,
@@ -3588,12 +3719,12 @@ func (_c *MockMessageRouter_RegisterHandler_Call) Run(run func(name string, hand
3588 return _c3719 return _c
3589 }3720 }
3590 3721
3591-func (_c *MockMessageRouter_RegisterHandler_Call) Return() *MockMessageRouter_RegisterHandler_Call {3722+func (_c *MockSchemeHandler_RegisterScheme_Call) Return() *MockSchemeHandler_RegisterScheme_Call {
3592 _c.Call.Return()3723 _c.Call.Return()
3593 return _c3724 return _c
3594 }3725 }
3595 3726
3596-func (_c *MockMessageRouter_RegisterHandler_Call) RunAndReturn(run func(name string, handler func(message string) (string, error))) *MockMessageRouter_RegisterHandler_Call {3727+func (_c *MockSchemeHandler_RegisterScheme_Call) RunAndReturn(run func(scheme string, handler func(uri string) ([]byte, string, error))) *MockSchemeHandler_RegisterScheme_Call {
3597 _c.Run(run)3728 _c.Run(run)
3598 return _c3729 return _c
3599 }3730 }
@@ -4294,8 +4425,8 @@ func (_c *MockFaviconService_Get_Call) RunAndReturn(run func(ctx context.Context
4294 }4425 }
4295 4426
4296 // GetCached provides a mock function for the type MockFaviconService4427 // GetCached provides a mock function for the type MockFaviconService
4297-func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {4428+func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) {
4298- ret := _mock.Called(domain)4429+ ret := _mock.Called(ctx, domain)
4299 4430
4300 if len(ret) == 0 {4431 if len(ret) == 0 {
4301 panic("no return value specified for GetCached")4432 panic("no return value specified for GetCached")
@@ -4303,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {
4303 4434
4304 var r0 []byte4435 var r0 []byte
4305 var r1 bool4436 var r1 bool
4306- if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok {4437+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok {
4307- return returnFunc(domain)4438+ return returnFunc(ctx, domain)
4308 }4439 }
4309- if returnFunc, ok := ret.Get(0).(func(string) []byte); ok {4440+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok {
4310- r0 = returnFunc(domain)4441+ r0 = returnFunc(ctx, domain)
4311 } else {4442 } else {
4312 if ret.Get(0) != nil {4443 if ret.Get(0) != nil {
4313 r0 = ret.Get(0).([]byte)4444 r0 = ret.Get(0).([]byte)
4314 }4445 }
4315 }4446 }
4316- if returnFunc, ok := ret.Get(1).(func(string) bool); ok {4447+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
4317- r1 = returnFunc(domain)4448+ r1 = returnFunc(ctx, domain)
4318 } else {4449 } else {
4319 r1 = ret.Get(1).(bool)4450 r1 = ret.Get(1).(bool)
4320 }4451 }
@@ -4327,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct {
4327 }4458 }
4328 4459
4329 // GetCached is a helper method to define mock.On call4460 // GetCached is a helper method to define mock.On call
4461+// - ctx context.Context
4330 // - domain string4462 // - domain string
4331-func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call {4463+func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call {
4332- return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)}4464+ return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", ctx, domain)}
4333 }4465 }
4334 4466
4335-func (_c *MockFaviconService_GetCached_Call) Run(run func(domain string)) *MockFaviconService_GetCached_Call {4467+func (_c *MockFaviconService_GetCached_Call) Run(run func(ctx context.Context, domain string)) *MockFaviconService_GetCached_Call {
4336 _c.Call.Run(func(args mock.Arguments) {4468 _c.Call.Run(func(args mock.Arguments) {
4337- var arg0 string4469+ var arg0 context.Context
4338 if args[0] != nil {4470 if args[0] != nil {
4339- arg0 = args[0].(string)4471+ arg0 = args[0].(context.Context)
4472+ }
4473+ var arg1 string
4474+ if args[1] != nil {
4475+ arg1 = args[1].(string)
4340 }4476 }
4341 run(4477 run(
4342 arg0,4478 arg0,
4479+ arg1,
4343 )4480 )
4344 })4481 })
4345 return _c4482 return _c
@@ -4350,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF
4350 return _c4487 return _c
4351 }4488 }
4352 4489
4353-func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {4490+func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {
4354 _c.Call.Return(run)4491 _c.Call.Return(run)
4355 return _c4492 return _c
4356 }4493 }
A internal/application/port/transcoder.go +35 -0
@@ -0,0 +1,35 @@
1+package port
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// TranscodeSession represents an active GPU transcoding session.
9+// Read returns transcoded chunks. Close aborts and frees GPU resources.
10+type TranscodeSession interface {
11+ io.ReadCloser
12+ // ContentType returns the MIME type of the transcoded output (e.g., "video/webm").
13+ ContentType() string
14+}
15+
16+// HWCapabilities describes the detected GPU hardware encoding capabilities.
17+type HWCapabilities struct {
18+ API string // "vaapi", "nvenc", or "" (none available)
19+ Device string // auto-detected device path
20+ Encoders []string // e.g., ["av1_vaapi"]
21+ Decoders []string // e.g., ["h264_vaapi"]
22+}
23+
24+// MediaTranscoder handles GPU-accelerated video transcoding.
25+type MediaTranscoder interface {
26+ // Available returns true if a compatible GPU encoder was found.
27+ Available() bool
28+ // Capabilities returns the detected GPU hardware info.
29+ Capabilities() HWCapabilities
30+ // Start begins a GPU transcode session, fetching from sourceURL.
31+ // Headers are forwarded to the source HTTP request (cookies, auth).
32+ Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error)
33+ // Close shuts down all active transcode sessions and releases resources.
34+ Close()
35+}
M internal/application/usecase/delete_session.go +2 -5
@@ -19,19 +19,16 @@ var (
19 type DeleteSessionUseCase struct {19 type DeleteSessionUseCase struct {
20 stateRepo repository.SessionStateRepository20 stateRepo repository.SessionStateRepository
21 sessionRepo repository.SessionRepository21 sessionRepo repository.SessionRepository
22- lockDir string
23 }22 }
24 23
25 // NewDeleteSessionUseCase creates a new DeleteSessionUseCase.24 // NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26 func NewDeleteSessionUseCase(25 func NewDeleteSessionUseCase(
27 stateRepo repository.SessionStateRepository,26 stateRepo repository.SessionStateRepository,
28 sessionRepo repository.SessionRepository,27 sessionRepo repository.SessionRepository,
29- lockDir string,
30 ) *DeleteSessionUseCase {28 ) *DeleteSessionUseCase {
31 return &DeleteSessionUseCase{29 return &DeleteSessionUseCase{
32 stateRepo: stateRepo,30 stateRepo: stateRepo,
33 sessionRepo: sessionRepo,31 sessionRepo: sessionRepo,
34- lockDir: lockDir,
35 }32 }
36 }33 }
37 34
@@ -60,8 +57,8 @@ func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSession
60 return ErrSessionNotFound57 return ErrSessionNotFound
61 }58 }
62 59
63- // Check not active (EndedAt is nil OR has lock file)60+ // Don't delete active sessions.
64- if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {61+ if session.IsActive() {
65 return ErrCannotDeleteActiveSession62 return ErrCannotDeleteActiveSession
66 }63 }
67 64
M internal/application/usecase/delete_session_test.go +8 -44
@@ -2,8 +2,6 @@ package usecase_test
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "os"
6- "path/filepath"
7 "testing"5 "testing"
8 "time"6 "time"
9 7
@@ -34,7 +32,7 @@ func TestDeleteSessionUseCase_Execute_Success(t *testing.T) {
34 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)32 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
35 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)33 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
36 34
37- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")35+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
38 36
39 err := uc.Execute(ctx, usecase.DeleteSessionInput{37 err := uc.Execute(ctx, usecase.DeleteSessionInput{
40 SessionID: sessionID,38 SessionID: sessionID,
@@ -51,7 +49,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) {
51 49
52 sessionID := entity.SessionID("20251224_120000_current")50 sessionID := entity.SessionID("20251224_120000_current")
53 51
54- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")52+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
55 53
56 err := uc.Execute(ctx, usecase.DeleteSessionInput{54 err := uc.Execute(ctx, usecase.DeleteSessionInput{
57 SessionID: sessionID,55 SessionID: sessionID,
@@ -71,7 +69,7 @@ func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) {
71 69
72 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)70 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)
73 71
74- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")72+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
75 73
76 err := uc.Execute(ctx, usecase.DeleteSessionInput{74 err := uc.Execute(ctx, usecase.DeleteSessionInput{
77 SessionID: sessionID,75 SessionID: sessionID,
@@ -99,7 +97,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
99 97
100 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)98 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
101 99
102- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")100+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
103 101
104 err := uc.Execute(ctx, usecase.DeleteSessionInput{102 err := uc.Execute(ctx, usecase.DeleteSessionInput{
105 SessionID: sessionID,103 SessionID: sessionID,
@@ -109,40 +107,6 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
109 assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)107 assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
110 }108 }
111 109
112-func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) {
113- ctx := testContext()
114-
115- stateRepo := repomocks.NewMockSessionStateRepository(t)
116- sessionRepo := repomocks.NewMockSessionRepository(t)
117-
118- // Create a temporary lock directory and lock file
119- lockDir := t.TempDir()
120- sessionID := entity.SessionID("20251224_120000_locked")
121- lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock")
122- f, err := os.Create(lockPath)
123- require.NoError(t, err)
124- f.Close()
125-
126- endedAt := time.Now().Add(-time.Hour)
127- session := &entity.Session{
128- ID: sessionID,
129- Type: entity.SessionTypeBrowser,
130- StartedAt: time.Now().Add(-2 * time.Hour),
131- EndedAt: &endedAt, // Ended, but has lock file
132- }
133-
134- sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
135-
136- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir)
137-
138- err = uc.Execute(ctx, usecase.DeleteSessionInput{
139- SessionID: sessionID,
140- CurrentSessionID: "other-session",
141- })
142- require.Error(t, err)
143- assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
144-}
145-
146 func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {110 func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
147 ctx := testContext()111 ctx := testContext()
148 112
@@ -153,7 +117,7 @@ func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
153 117
154 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))118 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))
155 119
156- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")120+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
157 121
158 err := uc.Execute(ctx, usecase.DeleteSessionInput{122 err := uc.Execute(ctx, usecase.DeleteSessionInput{
159 SessionID: sessionID,123 SessionID: sessionID,
@@ -184,7 +148,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) {
184 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))148 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))
185 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)149 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
186 150
187- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")151+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
188 152
189 err := uc.Execute(ctx, usecase.DeleteSessionInput{153 err := uc.Execute(ctx, usecase.DeleteSessionInput{
190 SessionID: sessionID,154 SessionID: sessionID,
@@ -214,7 +178,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) {
214 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)178 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
215 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))179 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))
216 180
217- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")181+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
218 182
219 err := uc.Execute(ctx, usecase.DeleteSessionInput{183 err := uc.Execute(ctx, usecase.DeleteSessionInput{
220 SessionID: sessionID,184 SessionID: sessionID,
@@ -244,7 +208,7 @@ func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) {
244 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)208 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
245 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)209 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
246 210
247- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")211+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
248 212
249 // CurrentSessionID is empty (e.g., CLI context)213 // CurrentSessionID is empty (e.g., CLI context)
250 err := uc.Execute(ctx, usecase.DeleteSessionInput{214 err := uc.Execute(ctx, usecase.DeleteSessionInput{
M internal/application/usecase/get_last_restorable_session.go +2 -6
@@ -21,19 +21,16 @@ const (
21 type GetLastRestorableSessionUseCase struct {21 type GetLastRestorableSessionUseCase struct {
22 sessionRepo repository.SessionRepository22 sessionRepo repository.SessionRepository
23 stateRepo repository.SessionStateRepository23 stateRepo repository.SessionStateRepository
24- lockDir string
25 }24 }
26 25
27 // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.26 // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.
28 func NewGetLastRestorableSessionUseCase(27 func NewGetLastRestorableSessionUseCase(
29 sessionRepo repository.SessionRepository,28 sessionRepo repository.SessionRepository,
30 stateRepo repository.SessionStateRepository,29 stateRepo repository.SessionStateRepository,
31- lockDir string,
32 ) *GetLastRestorableSessionUseCase {30 ) *GetLastRestorableSessionUseCase {
33 return &GetLastRestorableSessionUseCase{31 return &GetLastRestorableSessionUseCase{
34 sessionRepo: sessionRepo,32 sessionRepo: sessionRepo,
35 stateRepo: stateRepo,33 stateRepo: stateRepo,
36- lockDir: lockDir,
37 }34 }
38 }35 }
39 36
@@ -75,9 +72,8 @@ func (uc *GetLastRestorableSessionUseCase) Execute(
75 continue72 continue
76 }73 }
77 74
78- // Check if session is actively running75+ // Skip sessions that haven't been ended yet (still running or crashed).
79- // A session is "active" if ended_at IS NULL AND it has a lock file76+ if session.IsActive() {
80- if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) {
81 log.Debug().77 log.Debug().
82 Str("session_id", string(session.ID)).78 Str("session_id", string(session.ID)).
83 Msg("auto-restore: skipping active session")79 Msg("auto-restore: skipping active session")
M internal/application/usecase/get_last_restorable_session_test.go +14 -27
@@ -1,8 +1,6 @@
1 package usecase_test1 package usecase_test
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6 "testing"4 "testing"
7 "time"5 "time"
8 6
@@ -21,7 +19,7 @@ func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) {
21 19
22 sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)20 sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)
23 21
24- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")22+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
25 23
26 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{24 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
27 ExcludeSessionID: "current",25 ExcludeSessionID: "current",
@@ -53,7 +51,7 @@ func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) {
53 51
54 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)52 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
55 53
56- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")54+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
57 55
58 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{56 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
59 ExcludeSessionID: currentID,57 ExcludeSessionID: currentID,
@@ -82,7 +80,7 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
82 80
83 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)81 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
84 82
85- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")83+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
86 84
87 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{85 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
88 ExcludeSessionID: "current",86 ExcludeSessionID: "current",
@@ -92,23 +90,14 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
92 assert.Empty(t, output.SessionID)90 assert.Empty(t, output.SessionID)
93 }91 }
94 92
95-func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {93+func TestGetLastRestorableSessionUseCase_SkipsActiveSessions(t *testing.T) {
96 ctx := testContext()94 ctx := testContext()
97 95
98- // Create temp dir for lock files
99- lockDir := t.TempDir()
100-
101 sessionRepo := repomocks.NewMockSessionRepository(t)96 sessionRepo := repomocks.NewMockSessionRepository(t)
102 stateRepo := repomocks.NewMockSessionStateRepository(t)97 stateRepo := repomocks.NewMockSessionStateRepository(t)
103 98
104 activeID := entity.SessionID("20251225_120000_actv")99 activeID := entity.SessionID("20251225_120000_actv")
105 100
106- // Create lock file to simulate running session
107- lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock")
108- f, err := os.Create(lockPath)
109- require.NoError(t, err)
110- f.Close()
111-
112 sessions := []*entity.Session{101 sessions := []*entity.Session{
113 {102 {
114 ID: activeID,103 ID: activeID,
@@ -120,7 +109,7 @@ func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
120 109
121 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)110 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
122 111
123- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)112+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
124 113
125 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{114 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
126 ExcludeSessionID: "current",115 ExcludeSessionID: "current",
@@ -151,7 +140,7 @@ func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) {
151 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)140 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
152 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot141 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot
153 142
154- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")143+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
155 144
156 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{145 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
157 ExcludeSessionID: "current",146 ExcludeSessionID: "current",
@@ -189,7 +178,7 @@ func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) {
189 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)178 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
190 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)179 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)
191 180
192- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")181+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
193 182
194 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{183 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
195 ExcludeSessionID: "current",184 ExcludeSessionID: "current",
@@ -241,7 +230,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
241 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)230 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
242 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)231 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
243 232
244- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")233+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
245 234
246 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{235 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
247 ExcludeSessionID: "current",236 ExcludeSessionID: "current",
@@ -256,21 +245,19 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
256 func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {245 func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
257 ctx := testContext()246 ctx := testContext()
258 247
259- // Empty lock dir (no lock files)
260- lockDir := t.TempDir()
261-
262 sessionRepo := repomocks.NewMockSessionRepository(t)248 sessionRepo := repomocks.NewMockSessionRepository(t)
263 stateRepo := repomocks.NewMockSessionStateRepository(t)249 stateRepo := repomocks.NewMockSessionStateRepository(t)
264 250
265 sessionID := entity.SessionID("20251225_120000_crsh")251 sessionID := entity.SessionID("20251225_120000_crsh")
266 252
267- // Session with no ended_at (crashed) but no lock file253+ // Crashed session that was cleaned up by background cleanup (EndedAt set).
254+ endedAt := time.Now().Add(-30 * time.Minute)
268 sessions := []*entity.Session{255 sessions := []*entity.Session{
269 {256 {
270 ID: sessionID,257 ID: sessionID,
271 Type: entity.SessionTypeBrowser,258 Type: entity.SessionTypeBrowser,
272 StartedAt: time.Now().Add(-1 * time.Hour),259 StartedAt: time.Now().Add(-1 * time.Hour),
273- EndedAt: nil, // Not ended - simulates crash260+ EndedAt: &endedAt,
274 },261 },
275 }262 }
276 263
@@ -298,7 +285,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
298 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)285 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
299 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)286 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
300 287
301- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)288+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
302 289
303 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{290 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
304 ExcludeSessionID: "current",291 ExcludeSessionID: "current",
@@ -349,7 +336,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) {
349 stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)336 stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)
350 // Note: GetSnapshot for olderID should NOT be called since we return first match337 // Note: GetSnapshot for olderID should NOT be called since we return first match
351 338
352- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")339+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
353 340
354 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{341 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
355 ExcludeSessionID: "current",342 ExcludeSessionID: "current",
@@ -397,7 +384,7 @@ func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) {
397 stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot384 stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot
398 stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)385 stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)
399 386
400- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")387+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
401 388
402 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{389 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
403 ExcludeSessionID: "current",390 ExcludeSessionID: "current",
M internal/application/usecase/list_sessions.go +2 -10
@@ -13,19 +13,16 @@ import (
13 type ListSessionsUseCase struct {13 type ListSessionsUseCase struct {
14 sessionRepo repository.SessionRepository14 sessionRepo repository.SessionRepository
15 stateRepo repository.SessionStateRepository15 stateRepo repository.SessionStateRepository
16- lockDir string // Directory where lock files are stored
17 }16 }
18 17
19 // NewListSessionsUseCase creates a new ListSessionsUseCase.18 // NewListSessionsUseCase creates a new ListSessionsUseCase.
20 func NewListSessionsUseCase(19 func NewListSessionsUseCase(
21 sessionRepo repository.SessionRepository,20 sessionRepo repository.SessionRepository,
22 stateRepo repository.SessionStateRepository,21 stateRepo repository.SessionStateRepository,
23- lockDir string,
24 ) *ListSessionsUseCase {22 ) *ListSessionsUseCase {
25 return &ListSessionsUseCase{23 return &ListSessionsUseCase{
26 sessionRepo: sessionRepo,24 sessionRepo: sessionRepo,
27 stateRepo: stateRepo,25 stateRepo: stateRepo,
28- lockDir: lockDir,
29 }26 }
30 }27 }
31 28
@@ -73,7 +70,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
73 info := entity.SessionInfo{70 info := entity.SessionInfo{
74 Session: session,71 Session: session,
75 IsCurrent: session.ID == currentSessionID,72 IsCurrent: session.ID == currentSessionID,
76- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),73+ IsActive: session.IsActive(),
77 }74 }
78 75
79 if state, ok := snapshotMap[session.ID]; ok {76 if state, ok := snapshotMap[session.ID]; ok {
@@ -94,11 +91,6 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
94 return &ListSessionsOutput{Sessions: result}, nil91 return &ListSessionsOutput{Sessions: result}, nil
95 }92 }
96 93
97-// hasActiveLock checks if a session has an active lock file.
98-func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99- return isSessionLocked(uc.lockDir, sessionID)
100-}
101-
102 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.94 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
103 func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {95 func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
104 // Simple bubble sort for small lists96 // Simple bubble sort for small lists
@@ -149,7 +141,7 @@ func (uc *ListSessionsUseCase) GetSessionInfo(
149 info := &entity.SessionInfo{141 info := &entity.SessionInfo{
150 Session: session,142 Session: session,
151 IsCurrent: session.ID == currentSessionID,143 IsCurrent: session.ID == currentSessionID,
152- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),144+ IsActive: session.IsActive(),
153 UpdatedAt: session.StartedAt,145 UpdatedAt: session.StartedAt,
154 }146 }
155 147
M internal/application/usecase/list_sessions_test.go +4 -4
@@ -47,7 +47,7 @@ func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
47 sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)47 sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)48 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49 49
50- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
51 51
52 output, err := uc.Execute(ctx, "", 50)52 output, err := uc.Execute(ctx, "", 50)
53 require.NoError(t, err)53 require.NoError(t, err)
@@ -103,7 +103,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing
103 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)103 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)104 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105 105
106- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
107 107
108 output, err := uc.GetPurgeableSessions(ctx)108 output, err := uc.GetPurgeableSessions(ctx)
109 require.NoError(t, err)109 require.NoError(t, err)
@@ -139,7 +139,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.
139 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)139 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)140 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141 141
142- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
143 143
144 output, err := uc.GetPurgeableSessions(ctx)144 output, err := uc.GetPurgeableSessions(ctx)
145 require.NoError(t, err)145 require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing
167 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)167 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)168 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169 169
170- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
171 171
172 output, err := uc.GetPurgeableSessions(ctx)172 output, err := uc.GetPurgeableSessions(ctx)
173 // Should succeed even if size query fails173 // Should succeed even if size query fails
M internal/application/usecase/navigate.go +71 -29
@@ -32,6 +32,7 @@ const (
32 // historyRecord holds data for async history recording.32 // historyRecord holds data for async history recording.
33 type historyRecord struct {33 type historyRecord struct {
34 url string34 url string
35+ title string // non-empty for title-update-only records
35 visits int36 visits int
36 }37 }
37 38
@@ -227,23 +228,57 @@ func (uc *NavigateUseCase) historyWorker() {
227 ticker := time.NewTicker(historyWorkerFlushInterval)228 ticker := time.NewTicker(historyWorkerFlushInterval)
228 defer ticker.Stop()229 defer ticker.Stop()
229 230
230- pending := make(map[string]int)231+ pendingVisits := make(map[string]int)
232+ pendingTitles := make(map[string]string)
233+
234+ processRecord := func(record historyRecord) {
235+ if record.title != "" {
236+ pendingTitles[record.url] = record.title
237+ }
238+ if record.visits > 0 {
239+ pendingVisits[record.url] += record.visits
240+ }
241+ }
231 242
232 flushPending := func() {243 flushPending := func() {
233- if len(pending) == 0 {244+ visitCount := len(pendingVisits)
245+ titleCount := len(pendingTitles)
246+ if visitCount == 0 && titleCount == 0 {
234 return247 return
235 }248 }
236- for historyURL, visits := range pending {249+
237- uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})250+ log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251+
252+ // Swap maps first, then materialize immutable slices before invoking
253+ // persistence callbacks. This keeps any re-enqueued records isolated to
254+ // the fresh pending maps for the next flush cycle.
255+ visits := pendingVisits
256+ titles := pendingTitles
257+ pendingVisits = make(map[string]int)
258+ pendingTitles = make(map[string]string)
259+
260+ visitRecords := make([]historyRecord, 0, len(visits))
261+ for historyURL, v := range visits {
262+ visitRecords = append(visitRecords, historyRecord{url: historyURL, visits: v})
263+ }
264+ titleUpdates := make([]historyRecord, 0, len(titles))
265+ for historyURL, title := range titles {
266+ titleUpdates = append(titleUpdates, historyRecord{url: historyURL, title: title})
267+ }
268+
269+ for _, record := range visitRecords {
270+ uc.persistHistory(uc.ctx, record)
271+ }
272+ for _, record := range titleUpdates {
273+ uc.persistTitleUpdate(uc.ctx, record.url, record.title)
238 }274 }
239- clear(pending)
240 }275 }
241 276
242 drainQueue := func() {277 drainQueue := func() {
243 for {278 for {
244 select {279 select {
245 case record := <-uc.historyQueue:280 case record := <-uc.historyQueue:
246- pending[record.url] += record.visits281+ processRecord(record)
247 default:282 default:
248 return283 return
249 }284 }
@@ -253,7 +288,7 @@ func (uc *NavigateUseCase) historyWorker() {
253 for {288 for {
254 select {289 select {
255 case record := <-uc.historyQueue:290 case record := <-uc.historyQueue:
256- pending[record.url] += record.visits291+ processRecord(record)
257 case <-ticker.C:292 case <-ticker.C:
258 flushPending()293 flushPending()
259 case <-uc.done:294 case <-uc.done:
@@ -312,46 +347,53 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
312 }347 }
313 }348 }
314 349
315-// UpdateHistoryTitle updates the title of a history entry after page load.350+// persistTitleUpdate writes a title update to the database.
316-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {351+// Called from the background worker goroutine.
352+func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) {
317 log := logging.FromContext(ctx)353 log := logging.FromContext(ctx)
318 354
319- // Canonicalize URL the same way history records are persisted.355+ if title == "" {
320- historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{356+ return
321- StripTrackingParams: stripTrackingParamsForHistoryDedup,
322- })
323- if historyURL == "" {
324- log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update")
325- return nil
326 }357 }
327- log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
328 358
329 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
330 if err != nil {360 if err != nil {
331- return fmt.Errorf("failed to find history entry: %w", err)361+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
362+ return
332 }363 }
333-
334 if entry == nil {364 if entry == nil {
335- // Entry doesn't exist - don't create it here365+ return
336- // Initial navigation should have already created the entry
337- // This can happen if URL changed during page load (SPA, redirect)
338- log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
339- return nil
340 }366 }
341 367
342- // Update existing entry's title
343 entry.Title = title368 entry.Title = title
344 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
345 if err := updater.UpdateMetadata(ctx, entry); err != nil {370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
346- return fmt.Errorf("failed to update history metadata: %w", err)371+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
347 }372 }
348- return nil373+ return
349 }374 }
350 if err := uc.historyRepo.Save(ctx, entry); err != nil {375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
351- return fmt.Errorf("failed to update history title: %w", err)376+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
377+ }
378+}
379+
380+// UpdateHistoryTitle queues a title update for a history entry. The actual
381+// DB write happens in the background historyWorker to avoid concurrent
382+// repo access from the GTK main thread and the worker goroutine.
383+func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
384+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
385+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
386+ })
387+ if historyURL == "" {
388+ return
352 }389 }
353 390
354- return nil391+ // Non-blocking send — same queue as visit records.
392+ select {
393+ case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394+ default:
395+ logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
396+ }
355 }397 }
356 398
357 // Reload reloads the current page.399 // Reload reloads the current page.
M internal/application/usecase/navigate_test.go +68 -2
@@ -3,7 +3,9 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "path/filepath"5 "path/filepath"
6+ "sync"
6 "testing"7 "testing"
8+ "time"
7 9
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
9 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -79,13 +81,77 @@ func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testi
79 require.NotNil(t, before)81 require.NotNil(t, before)
80 82
81 uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)83 uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
82- err = uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")84+ uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
83- require.NoError(t, err)85+ uc.Close()
84 86
85 after, err := repo.FindByURL(ctx, "https://example.com/article")87 after, err := repo.FindByURL(ctx, "https://example.com/article")
86 require.NoError(t, err)88 require.NoError(t, err)
87 require.NotNil(t, after)89 require.NotNil(t, after)
88 require.Equal(t, "New", after.Title)90 require.Equal(t, "New", after.Title)
89 require.Equal(t, before.VisitCount, after.VisitCount)91 require.Equal(t, before.VisitCount, after.VisitCount)
92+}
93+
94+func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
95+ ctx := context.Background()
96+
97+ // Stateful store — Save writes here, FindByURL reads from here.
98+ var mu sync.Mutex
99+ store := make(map[string]*entity.HistoryEntry)
100+
101+ // Fires once during the first Save to re-enqueue a title update while
102+ // the flush is in progress. This is the exact race that caused the
103+ // concurrent map crash (fixed by swapping maps in flushPending).
104+ var saveOnce sync.Once
105+ var uc *NavigateUseCase
106+
107+ repo := repomocks.NewMockHistoryRepository(t)
108+
109+ repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
110+ func(_ context.Context, url string) (*entity.HistoryEntry, error) {
111+ mu.Lock()
112+ defer mu.Unlock()
113+ e := store[url]
114+ if e == nil {
115+ return nil, nil
116+ }
117+ clone := *e
118+ return &clone, nil
119+ },
120+ ).Maybe()
121+
122+ repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
123+ func(_ context.Context, entry *entity.HistoryEntry) error {
124+ clone := *entry
125+ mu.Lock()
126+ store[entry.URL] = &clone
127+ mu.Unlock()
128+
129+ // Re-enqueue a title update during the first Save.
130+ saveOnce.Do(func() {
131+ if entry.URL == "https://example.com/article" {
132+ uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
133+ }
134+ })
135+ return nil
136+ },
137+ ).Maybe()
138+
139+ uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
140+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141+
142+ // The history worker flushes on a timer; there is no exported sync
143+ // mechanism, so we sleep before shutting down. Using 5× the flush
144+ // interval for headroom — this is a known flaky pattern, but
145+ // restructuring the worker for deterministic sync is out of scope.
146+ // Close() performs a final flush, so items re-enqueued during the
147+ // timed flush are still captured on shutdown.
148+ time.Sleep(historyWorkerFlushInterval * 5)
90 uc.Close()149 uc.Close()
150+
151+ mu.Lock()
152+ entry := store["https://example.com/article"]
153+ mu.Unlock()
154+ require.NotNil(t, entry)
155+ require.Equal(t, int64(1), entry.VisitCount)
156+ require.Equal(t, "Queued title", entry.Title)
91 }157 }
D internal/application/usecase/session_lock.go +0 -21
@@ -1,21 +0,0 @@
1-package usecase
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7-
8- "github.com/bnema/dumber/internal/domain/entity"
9-)
10-
11-// isSessionLocked checks if a session has an active lock file.
12-// Lock files are created in lockDir with format: session_<sessionID>.lock
13-// This matches the format used by bootstrap.sessionLockPath().
14-func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15- if lockDir == "" {
16- return false
17- }
18- lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19- _, err := os.Stat(lockPath)
20- return err == nil
21-}
M internal/bootstrap/crash_report.go +91 -153
@@ -11,7 +11,6 @@ import (
11 "regexp"11 "regexp"
12 "runtime"12 "runtime"
13 "sort"13 "sort"
14- "strconv"
15 "strings"14 "strings"
16 "time"15 "time"
17 16
@@ -25,23 +24,20 @@ const (
25 scannerMaxTokenSize = 256 * 102424 scannerMaxTokenSize = 256 * 1024
26 )25 )
27 26
28-type unexpectedCloseReport struct {27+type crashReport struct {
29- ReportVersion int `json:"report_version"`28+ ReportVersion int `json:"report_version"`
30- GeneratedAt string `json:"generated_at"`29+ GeneratedAt string `json:"generated_at"`
31- SessionID string `json:"session_id"`30+ SessionID string `json:"session_id"`
32- Classification SessionExitClassification `json:"classification"`31+ CrashedPID int `json:"crashed_pid,omitempty"`
33- StartupPID int `json:"startup_pid,omitempty"`32+ SessionLogFile string `json:"session_log_file,omitempty"`
34- StartupPPID int `json:"startup_ppid,omitempty"`33+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
35- MarkerFiles map[string]string `json:"marker_files"`34+ ReporterProcess crashReporter `json:"reporter_process"`
36- SessionLogFile string `json:"session_log_file,omitempty"`35+ CoreDumpDiagnostics crashCoreDump `json:"core_dump_diagnostics"`
37- SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`36+ IssueTemplate crashIssueTemplate `json:"issue_template"`
38- ReporterProcess unexpectedCloseReporter `json:"reporter_process"`37+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
39- CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40- IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41- GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
42 }38 }
43 39
44-type unexpectedCloseReporter struct {40+type crashReporter struct {
45 GeneratedBy string `json:"generated_by"`41 GeneratedBy string `json:"generated_by"`
46 GoVersion string `json:"go_version"`42 GoVersion string `json:"go_version"`
47 GOOS string `json:"goos"`43 GOOS string `json:"goos"`
@@ -50,33 +46,26 @@ type unexpectedCloseReporter struct {
50 PPID int `json:"ppid"`46 PPID int `json:"ppid"`
51 }47 }
52 48
53-type unexpectedCloseCoreDump struct {49+type crashCoreDump struct {
54 RLimitCoreSoft string `json:"rlimit_core_soft"`50 RLimitCoreSoft string `json:"rlimit_core_soft"`
55 RLimitCoreHard string `json:"rlimit_core_hard"`51 RLimitCoreHard string `json:"rlimit_core_hard"`
56 Hint string `json:"hint"`52 Hint string `json:"hint"`
57 }53 }
58 54
59-type unexpectedCloseIssueTemplate struct {55+type crashIssueTemplate struct {
60 Title string `json:"title"`56 Title string `json:"title"`
61 Summary string `json:"summary"`57 Summary string `json:"summary"`
62 }58 }
63 59
64-func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {60+// writeCrashReport generates JSON + Markdown crash reports for a crashed session.
65- if lockDir == "" || sessionID == "" {61+// Returns the JSON report path, or empty string if the report already exists.
66- return "", errors.New("lockDir and sessionID are required")62+func writeCrashReport(logDir, sessionID string, crashedPID int) (string, error) {
63+ if logDir == "" || sessionID == "" {
64+ return "", errors.New("logDir and sessionID are required")
67 }65 }
68 66
69- classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)67+ reportsDir := filepath.Join(logDir, crashReportsDirName)
70- if classifyErr != nil {68+ if err := os.MkdirAll(reportsDir, dirPerm); err != nil {
71- return "", classifyErr
72- }
73-
74- if classification.Class == SessionExitCleanExit {
75- return "", nil
76- }
77-
78- reportsDir := filepath.Join(lockDir, crashReportsDirName)
79- if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80 return "", err69 return "", err
81 }70 }
82 71
@@ -84,47 +73,34 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
84 73
85 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))74 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))75 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87- if _, statErr := os.Stat(jsonPath); statErr == nil {76+
88- if _, mdErr := os.Stat(markdownPath); mdErr == nil {77+ // Skip if report already exists.
89- return jsonPath, nil78+ if _, err := os.Stat(jsonPath); err == nil {
90- }
91- raw, readErr := os.ReadFile(jsonPath)
92- if readErr != nil {
93- return "", readErr
94- }
95- var existing unexpectedCloseReport
96- if err := json.Unmarshal(raw, &existing); err != nil {
97- return "", err
98- }
99- markdown := buildUnexpectedCloseMarkdown(existing)
100- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101- return "", err
102- }
103 return jsonPath, nil79 return jsonPath, nil
104- } else if !os.IsNotExist(statErr) {
105- return "", statErr
106 }80 }
107 81
108- report := unexpectedCloseReport{82+ report := crashReport{
109- ReportVersion: 1,83+ ReportVersion: 2,
110- GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),84+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111- SessionID: sessionID,85+ SessionID: sessionID,
112- Classification: classification,86+ CrashedPID: crashedPID,
113- MarkerFiles: reportMarkerFiles(lockDir, sessionID),87+ ReporterProcess: crashReporter{
114- ReporterProcess: currentReporter(),88+ GeneratedBy: "dumber",
89+ GoVersion: runtime.Version(),
90+ GOOS: runtime.GOOS,
91+ GOARCH: runtime.GOARCH,
92+ PID: os.Getpid(),
93+ PPID: os.Getppid(),
94+ },
115 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),95 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116- IssueTemplate: unexpectedCloseIssueTemplate{96+ IssueTemplate: crashIssueTemplate{
117 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),97 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
118 Summary: "Dumber closed unexpectedly and generated this report automatically.",98 Summary: "Dumber closed unexpectedly and generated this report automatically.",
119 },99 },
120 }100 }
121 101
122- startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)102+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
123- report.StartupPID = startupPID103+ if _, err := os.Stat(logPath); err == nil {
124- report.StartupPPID = startupPPID
125-
126- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127- if _, statErr := os.Stat(logPath); statErr == nil {
128 report.SessionLogFile = logPath104 report.SessionLogFile = logPath
129 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)105 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130 }106 }
@@ -136,113 +112,35 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
136 return "", err112 return "", err
137 }113 }
138 payload = append(payload, '\n')114 payload = append(payload, '\n')
139- if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {115+ if err := os.WriteFile(jsonPath, payload, filePerm); err != nil {
140 return "", err116 return "", err
141 }117 }
142 118
143- markdown := buildUnexpectedCloseMarkdown(report)119+ markdown := buildCrashMarkdown(report)
144- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {120+ if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil {
145 return "", err121 return "", err
146 }122 }
147 123
148 return jsonPath, nil124 return jsonPath, nil
149 }125 }
150 126
151-func reportMarkerFiles(lockDir, sessionID string) map[string]string {127+func buildCrashMarkdown(report crashReport) string {
152- return map[string]string{
153- "startup": startupMarkerPath(lockDir, sessionID),
154- "shutdown": shutdownMarkerPath(lockDir, sessionID),
155- "abrupt": abruptMarkerPath(lockDir, sessionID),
156- }
157-}
158-
159-func currentReporter() unexpectedCloseReporter {
160- return unexpectedCloseReporter{
161- GeneratedBy: "dumber",
162- GoVersion: runtime.Version(),
163- GOOS: runtime.GOOS,
164- GOARCH: runtime.GOARCH,
165- PID: os.Getpid(),
166- PPID: os.Getppid(),
167- }
168-}
169-
170-func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171- raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172- if err != nil {
173- raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174- if err != nil {
175- return 0, 0
176- }
177- }
178-
179- pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180- ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181- return pid, ppid
182-}
183-
184-func readRedactedLogTail(path string, lines int) []string {
185- if lines <= 0 {
186- return nil
187- }
188-
189- file, err := os.Open(path)
190- if err != nil {
191- return nil
192- }
193- defer func() { _ = file.Close() }()
194-
195- ring := make([]string, lines)
196- count := 0
197- scanner := bufio.NewScanner(file)
198- scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199- for scanner.Scan() {
200- ring[count%lines] = redactSensitiveContent(scanner.Text())
201- count++
202- }
203-
204- var result []string
205- if count <= lines {
206- result = make([]string, count)
207- copy(result, ring[:count])
208- } else {
209- result = make([]string, lines)
210- start := count % lines
211- copy(result, ring[start:])
212- copy(result[lines-start:], ring[:start])
213- }
214-
215- if scanner.Err() != nil {
216- result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217- }
218- return result
219-}
220-
221-func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222 lines := []string{128 lines := []string{
223 "# Unexpected Close Report",129 "# Unexpected Close Report",
224 "",130 "",
225 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),131 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226 fmt.Sprintf("Session: `%s`", report.SessionID),132 fmt.Sprintf("Session: `%s`", report.SessionID),
227- fmt.Sprintf("Class: `%s`", report.Classification.Class),133+ fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
228- fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229- fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230 "",134 "",
231- "## Process Context",135+ "## Reporter",
232- fmt.Sprintf("- startup pid: `%d`", report.StartupPID),136+ fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
233- fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),137+ fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
234- fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),138+ fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
235- fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236 "",139 "",
237 "## Core Dump Diagnostics",140 "## Core Dump Diagnostics",
238 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),141 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),142 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),143 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241- "",
242- "## Marker Files",
243- fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244- fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245- fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246 }144 }
247 145
248 if len(report.SessionLogTail) > 0 {146 if len(report.SessionLogTail) > 0 {
@@ -266,8 +164,7 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
266 "",164 "",
267 "### Crash report",165 "### Crash report",
268 fmt.Sprintf("- session id: `%s`", report.SessionID),166 fmt.Sprintf("- session id: `%s`", report.SessionID),
269- fmt.Sprintf("- class: `%s`", report.Classification.Class),167+ fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
270- fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271 "",168 "",
272 "### Additional context",169 "### Additional context",
273 "- distro:",170 "- distro:",
@@ -279,6 +176,47 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
279 return strings.Join(lines, "\n") + "\n"176 return strings.Join(lines, "\n") + "\n"
280 }177 }
281 178
179+// ---------------------------------------------------------------------------
180+// Redaction helpers (kept from previous implementation)
181+// ---------------------------------------------------------------------------
182+
183+func readRedactedLogTail(path string, lines int) []string {
184+ if lines <= 0 {
185+ return nil
186+ }
187+
188+ file, err := os.Open(path)
189+ if err != nil {
190+ return nil
191+ }
192+ defer func() { _ = file.Close() }()
193+
194+ ring := make([]string, lines)
195+ count := 0
196+ scanner := bufio.NewScanner(file)
197+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
198+ for scanner.Scan() {
199+ ring[count%lines] = redactSensitiveContent(scanner.Text())
200+ count++
201+ }
202+
203+ var result []string
204+ if count <= lines {
205+ result = make([]string, count)
206+ copy(result, ring[:count])
207+ } else {
208+ result = make([]string, lines)
209+ start := count % lines
210+ copy(result, ring[start:])
211+ copy(result[lines-start:], ring[:start])
212+ }
213+
214+ if scanner.Err() != nil {
215+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
216+ }
217+ return result
218+}
219+
282 var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)220 var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283 221
284 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)222 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
M internal/bootstrap/crash_report_other.go +2 -2
@@ -2,8 +2,8 @@
2 2
3 package bootstrap3 package bootstrap
4 4
5-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {5+func collectCoreDumpDiagnostics() crashCoreDump {
6- return unexpectedCloseCoreDump{6+ return crashCoreDump{
7 RLimitCoreSoft: "unsupported",7 RLimitCoreSoft: "unsupported",
8 RLimitCoreHard: "unsupported",8 RLimitCoreHard: "unsupported",
9 Hint: "Core dump diagnostics are only available on Linux and macOS.",9 Hint: "Core dump diagnostics are only available on Linux and macOS.",
M internal/bootstrap/crash_report_test.go +9 -20
@@ -7,7 +7,6 @@ import (
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9 "testing"9 "testing"
10- "time"
11 10
12 corelogging "github.com/bnema/dumber/internal/logging"11 corelogging "github.com/bnema/dumber/internal/logging"
13 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -65,7 +64,7 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
65 for i := 0; i < 10; i++ {64 for i := 0; i < 10; i++ {
66 lines = append(lines, fmt.Sprintf("line %d", i))65 lines = append(lines, fmt.Sprintf("line %d", i))
67 }66 }
68- require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))67+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), filePerm))
69 68
70 t.Run("fewer_lines_than_requested", func(t *testing.T) {69 t.Run("fewer_lines_than_requested", func(t *testing.T) {
71 result := readRedactedLogTail(path, 20)70 result := readRedactedLogTail(path, 20)
@@ -83,23 +82,18 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
83 })82 })
84 }83 }
85 84
86-func TestWriteUnexpectedCloseReport(t *testing.T) {85+func TestWriteCrashReport(t *testing.T) {
87- lockDir := t.TempDir()86+ logDir := t.TempDir()
88 sessionID := "crash-test-1"87 sessionID := "crash-test-1"
89- startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
90 88
91- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))89+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
92- _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
93- require.NoError(t, err)
94-
95- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
96 logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +90 logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
97 "\n" +91 "\n" +
98 `{"level":"warn","message":"callback code=abc token=def"}` +92 `{"level":"warn","message":"callback code=abc token=def"}` +
99 "\n"93 "\n"
100- require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))94+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), filePerm))
101 95
102- jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)96+ jsonPath, err := writeCrashReport(logDir, sessionID, 12345)
103 require.NoError(t, err)97 require.NoError(t, err)
104 require.NotEmpty(t, jsonPath)98 require.NotEmpty(t, jsonPath)
105 assert.FileExists(t, jsonPath)99 assert.FileExists(t, jsonPath)
@@ -113,20 +107,15 @@ func TestWriteUnexpectedCloseReport(t *testing.T) {
113 var payload map[string]any107 var payload map[string]any
114 require.NoError(t, json.Unmarshal(raw, &payload))108 require.NoError(t, json.Unmarshal(raw, &payload))
115 assert.Equal(t, sessionID, payload["session_id"])109 assert.Equal(t, sessionID, payload["session_id"])
116-110+ assert.InDelta(t, float64(12345), payload["crashed_pid"], 0)
117- classificationRaw, ok := payload["classification"].(map[string]any)
118- require.True(t, ok, "classification should be a map[string]any")
119- classValue, ok := classificationRaw["class"].(string)
120- require.True(t, ok, "classification.class should be a string")
121- assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
122 111
123 tail, ok := payload["session_log_tail_redacted"].([]any)112 tail, ok := payload["session_log_tail_redacted"].([]any)
124 require.True(t, ok, "session_log_tail_redacted should be a []any")113 require.True(t, ok, "session_log_tail_redacted should be a []any")
125 require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")114 require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126 first, ok := tail[0].(string)115 first, ok := tail[0].(string)
127- require.True(t, ok, "first tail entry should be a string")116+ require.True(t, ok)
128 last, ok := tail[len(tail)-1].(string)117 last, ok := tail[len(tail)-1].(string)
129- require.True(t, ok, "last tail entry should be a string")118+ require.True(t, ok)
130 text := first + last119 text := first + last
131 assert.NotContains(t, text, "?a=1&b=2")120 assert.NotContains(t, text, "?a=1&b=2")
132 assert.NotContains(t, text, "code=abc")121 assert.NotContains(t, text, "code=abc")
M internal/bootstrap/crash_report_unix.go +6 -4
@@ -8,14 +8,16 @@ import (
8 "golang.org/x/sys/unix"8 "golang.org/x/sys/unix"
9 )9 )
10 10
11-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {11+const unknownDiag = "unknown"
12- d := unexpectedCloseCoreDump{12+
13+func collectCoreDumpDiagnostics() crashCoreDump {
14+ d := crashCoreDump{
13 Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",15 Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14 }16 }
15 var limit unix.Rlimit17 var limit unix.Rlimit
16 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {18 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17- d.RLimitCoreSoft = string(SessionExitUnknown)19+ d.RLimitCoreSoft = unknownDiag
18- d.RLimitCoreHard = string(SessionExitUnknown)20+ d.RLimitCoreHard = unknownDiag
19 return d21 return d
20 }22 }
21 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)23 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
M internal/bootstrap/engine.go +57 -63
@@ -2,13 +2,17 @@ package bootstrap
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/application/usecase"9+ audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10+ "github.com/bnema/dumber/internal/infrastructure/cef"
9 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/env"
13+ "github.com/bnema/dumber/internal/infrastructure/handlers"
14+ "github.com/bnema/dumber/internal/infrastructure/transcoder"
10 "github.com/bnema/dumber/internal/infrastructure/webkit"15 "github.com/bnema/dumber/internal/infrastructure/webkit"
11- "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
12 "github.com/bnema/dumber/internal/ui/theme"16 "github.com/bnema/dumber/internal/ui/theme"
13 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
14 )18 )
@@ -25,15 +29,11 @@ type EngineInput struct {
25 }29 }
26 30
27 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.31 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
28-// Currently only "webkit" is supported; "cef" and other types return an error.
29 func BuildEngine(input EngineInput) (port.Engine, error) {32 func BuildEngine(input EngineInput) (port.Engine, error) {
30 cfg := input.Config33 cfg := input.Config
31- engineType := cfg.Engine.Type34+ engineType := cfg.Engine.ResolveEngineType()
32- if engineType == "" {
33- engineType = "webkit"
34- }
35 switch engineType {35 switch engineType {
36- case "webkit":36+ case config.EngineTypeWebKit:
37 opts := port.EngineOptions{37 opts := port.EngineOptions{
38 DataDir: input.DataDir,38 DataDir: input.DataDir,
39 CacheDir: input.CacheDir,39 CacheDir: input.CacheDir,
@@ -41,66 +41,60 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
41 }41 }
42 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)42 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)
43 43
44- // Pre-build keybindings handler for handler registration.
45- keybindingsHandler, err := buildKeybindingsHandler()
46- if err != nil {
47- return nil, fmt.Errorf("failed to build keybindings handler: %w", err)
48- }
49-
50- // Pre-build config save function for handler registration.
51- saveConfigFunc, err := buildSaveConfigFunc()
52- if err != nil {
53- return nil, fmt.Errorf("failed to build save config func: %w", err)
54- }
55-
56 return webkit.NewEngine(44 return webkit.NewEngine(
57 input.Ctx, cfg, opts, wkCfg,45 input.Ctx, cfg, opts, wkCfg,
58 input.ThemeManager, input.ColorResolver, input.Logger,46 input.ThemeManager, input.ColorResolver, input.Logger,
59- func(ctx context.Context, router *webkit.MessageRouter, deps port.HandlerDependencies) error {
60- return handlers.RegisterAll(ctx, router, handlers.Config{
61- HistoryUC: deps.HistoryUC,
62- FavoritesUC: deps.FavoritesUC,
63- Clipboard: deps.Clipboard,
64- AutoCopyConfig: deps.AutoCopyConfig,
65- SaveConfig: saveConfigFunc,
66- KeybindingsHandler: keybindingsHandler,
67- OnClipboardCopied: deps.OnClipboardCopied,
68- })
69- },
70- handlers.RegisterAccentHandlers,
71 )47 )
72- case "cef":48+ case config.EngineTypeCEF:
73- return nil, fmt.Errorf("CEF engine not yet implemented")49+ opts := port.EngineOptions{
50+ DataDir: input.DataDir,
51+ CacheDir: input.CacheDir,
52+ CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
53+ }
54+ cefCfg := cef.RuntimeConfig{
55+ CEFDir: cfg.Engine.CEF.CEFDir,
56+ LogFile: cfg.Engine.CEF.LogFile,
57+ LogSeverity: cfg.Engine.CEF.LogSeverity,
58+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
59+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
60+ EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler,
61+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
62+ }
63+ transcodingCfg := cef.TranscodingRuntimeConfig{
64+ Enabled: cfg.Transcoding.Enabled,
65+ HWAccel: cfg.Transcoding.HWAccel,
66+ MaxConcurrent: cfg.Transcoding.MaxConcurrent,
67+ Quality: cfg.Transcoding.Quality,
68+ }
69+ surveyor := env.NewHardwareSurveyor()
70+ buildConfigPayload := func(cfgf func() *config.Config) func() ([]byte, error) {
71+ return func() ([]byte, error) {
72+ cfg := cfgf()
73+ var hw *port.HardwareInfo
74+ if surveyor != nil {
75+ survey := surveyor.Survey(context.Background())
76+ hw = &survey
77+ }
78+ return json.Marshal(config.BuildWebUIConfigPayload(cfg, hw))
79+ }
80+ }
81+ deps := cef.EngineDependencies{
82+ RegisterHandlers: handlers.RegisterAll,
83+ RegisterAccentHandlers: handlers.RegisterAccentHandlers,
84+ CurrentConfigPayload: buildConfigPayload(config.Get),
85+ DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
86+ MediaClassifier: cef.MediaClassifier{
87+ IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
88+ IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
89+ IsStreamingManifestMIME: transcoder.IsStreamingManifestMIME,
90+ IsStreamingManifestURL: transcoder.IsStreamingManifestURL,
91+ IsEagerTranscodeURL: transcoder.IsEagerTranscodeURL,
92+ ParseSyntheticTranscodeURL: transcoder.ParseSyntheticTranscodeURL,
93+ },
94+ }
95+ audioFactory := audiofactory.NewAudioOutputFactory()
96+ return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps)
74 default:97 default:
75 return nil, fmt.Errorf("unknown engine type: %q", engineType)98 return nil, fmt.Errorf("unknown engine type: %q", engineType)
76 }99 }
77 }100 }
78-
79-// buildKeybindingsHandler constructs the keybindings handler using the config manager.
80-func buildKeybindingsHandler() (*handlers.KeybindingsHandler, error) {
81- mgr := config.GetManager()
82- if mgr == nil {
83- return nil, fmt.Errorf("config manager not initialized")
84- }
85-
86- gateway := config.NewKeybindingsGateway(mgr)
87-
88- return handlers.NewKeybindingsHandler(
89- usecase.NewGetKeybindingsUseCase(gateway),
90- usecase.NewSetKeybindingUseCase(gateway, gateway),
91- usecase.NewResetKeybindingUseCase(gateway),
92- usecase.NewResetAllKeybindingsUseCase(gateway),
93- ), nil
94-}
95-
96-// buildSaveConfigFunc constructs the config save function using the config manager.
97-// It returns an error immediately if the config manager is not initialized, consistent
98-// with buildKeybindingsHandler.
99-func buildSaveConfigFunc() (func(context.Context, port.WebUIConfig) error, error) {
100- mgr := config.GetManager()
101- if mgr == nil {
102- return nil, fmt.Errorf("config manager not initialized")
103- }
104- uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
105- return uc.Execute, nil
106-}
M internal/bootstrap/engine_test.go +21 -2
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/infrastructure/cef"
7 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
@@ -16,10 +17,28 @@ func TestBuildEngine_UnknownType(t *testing.T) {
16 require.Contains(t, err.Error(), "unknown engine type")17 require.Contains(t, err.Error(), "unknown engine type")
17 }18 }
18 19
19-func TestBuildEngine_CEF_NotImplemented(t *testing.T) {20+func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
20 cfg := config.DefaultConfig()21 cfg := config.DefaultConfig()
21 cfg.Engine.Type = "cef"22 cfg.Engine.Type = "cef"
23+ cfg.Engine.CEF.CEFDir = t.TempDir()
22 _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})24 _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
23 require.Error(t, err)25 require.Error(t, err)
24- require.Contains(t, err.Error(), "not yet implemented")26+ require.Contains(t, err.Error(), "cef.InitWithApp")
27+}
28+
29+func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) {
30+ cfg := config.DefaultConfig()
31+ cfg.Engine.Type = "cef"
32+ cfg.Engine.CookiePolicy = config.CookiePolicyNever
33+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
34+ require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
35+}
36+
37+func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
38+ cfg := config.DefaultConfig()
39+ cfg.Engine.Type = "cef"
40+ cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty
41+ cfg.Engine.CEF.CEFDir = t.TempDir()
42+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
43+ require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
25 }44 }
M internal/bootstrap/gui.go +1 -1
@@ -148,7 +148,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
148 }148 }
149 149
150 // RunDeferredInit runs deferred initialization checks off the critical path.150 // RunDeferredInit runs deferred initialization checks off the critical path.
151-// This includes SQLite WASM precompile, runtime requirements, and media checks.151+// This includes runtime requirements and media checks.
152 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {152 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
153 var (153 var (
154 runtimeErr error154 runtimeErr error
A internal/bootstrap/handler_deps.go +31 -0
@@ -0,0 +1,31 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+)
11+
12+// BuildHandlerDeps constructs handler dependencies from the config manager.
13+// It combines keybinding use cases and config save into a single struct.
14+// The context is accepted for future use (e.g., passing to constructors that may need cancellation).
15+func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) {
16+ mgr := config.GetManager()
17+ if mgr == nil {
18+ return nil, fmt.Errorf("config manager not initialized")
19+ }
20+
21+ gateway := config.NewKeybindingsGateway(mgr)
22+ saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
23+
24+ return &port.HandlerDeps{
25+ SaveConfig: saveUC.Execute,
26+ KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway),
27+ KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway),
28+ KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway),
29+ AllKeybindingsResetter: usecase.NewResetAllKeybindingsUseCase(gateway),
30+ }, nil
31+}
M internal/bootstrap/session.go +173 -477
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strconv"
9 "strings"10 "strings"
10 "sync"11 "sync"
11 "syscall"12 "syscall"
@@ -23,41 +24,48 @@ import (
23 24
24 const (25 const (
25 recentSessionsLimit = 20026 recentSessionsLimit = 200
26- lockDirPerm = 0o75527+ dirPerm = 0o755
27- lockFilePerm = 0o60028+ filePerm = 0o644
28- markerFilePerm = 0o64429+ pidFileName = "dumber.pid"
29 )30 )
30 31
32+// BrowserSession holds the state for a running browser session.
31 type BrowserSession struct {33 type BrowserSession struct {
32 Session *entity.Session34 Session *entity.Session
33 Logger zerolog.Logger35 Logger zerolog.Logger
34 LogCleanup func()36 LogCleanup func()
37+ LogPath string
35 38
36- reportsMu sync.RWMutex39+ reportsMu sync.RWMutex
37- unexpectedCloseReportPath []string40+ crashReportPaths []string
38 41
39 Persist func(context.Context) error42 Persist func(context.Context) error
40 End func(context.Context) error43 End func(context.Context) error
41 }44 }
42 45
43-func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {46+// SetCrashReports stores crash report paths detected at startup.
47+func (s *BrowserSession) SetCrashReports(paths []string) {
44 if s == nil {48 if s == nil {
45 return49 return
46 }50 }
47 s.reportsMu.Lock()51 s.reportsMu.Lock()
48 defer s.reportsMu.Unlock()52 defer s.reportsMu.Unlock()
49- s.unexpectedCloseReportPath = append([]string(nil), paths...)53+ s.crashReportPaths = append([]string(nil), paths...)
50 }54 }
51 55
52-func (s *BrowserSession) UnexpectedCloseReports() []string {56+// CrashReports returns crash report paths detected at startup.
57+func (s *BrowserSession) CrashReports() []string {
53 if s == nil {58 if s == nil {
54 return nil59 return nil
55 }60 }
56 s.reportsMu.RLock()61 s.reportsMu.RLock()
57 defer s.reportsMu.RUnlock()62 defer s.reportsMu.RUnlock()
58- return append([]string(nil), s.unexpectedCloseReportPath...)63+ return append([]string(nil), s.crashReportPaths...)
59 }64 }
60 65
66+// StartBrowserSession creates and persists a browser session.
67+// It checks for previous crashes via PID file, creates the session log,
68+// and sets up cleanup.
61 func StartBrowserSession(69 func StartBrowserSession(
62 ctx context.Context,70 ctx context.Context,
63 cfg *config.Config,71 cfg *config.Config,
@@ -69,10 +77,10 @@ func StartBrowserSession(
69 return nil, ctx, errors.New("session repository is nil")77 return nil, ctx, errors.New("session repository is nil")
70 }78 }
71 79
72- lockDir := cfg.Logging.LogDir80+ logDir := cfg.Logging.LogDir
73- if lockDir == "" {81+ if logDir == "" {
74 if dir, err := config.GetLogDir(); err == nil {82 if dir, err := config.GetLogDir(); err == nil {
75- lockDir = dir83+ logDir = dir
76 }84 }
77 }85 }
78 86
@@ -102,34 +110,33 @@ func StartBrowserSession(
102 log.Warn().Err(err).Msg("failed to create session logger")110 log.Warn().Err(err).Msg("failed to create session logger")
103 }111 }
104 112
105- sessionCtx := corelogging.WithContext(context.Background(), logger)113+ // Compute and log the session log file path early so the user can
114+ // copy-paste it from the console output.
115+ var logPath string
116+ if logDir != "" {
117+ logPath = filepath.Join(logDir, corelogging.SessionFilename(string(session.ID)))
118+ logger.Info().Str("log_file", logPath).Msg("session log file")
119+ }
106 120
107- var (121+ sessionCtx := corelogging.WithContext(context.Background(), logger)
108- lockMu sync.Mutex
109- lockFile *os.File
110- lockPath string
111- persistMu sync.Once
112- persistErr error
113- )
114 122
115- browserSession := &BrowserSession{123+ bs := &BrowserSession{
116 Session: session,124 Session: session,
117 Logger: logger,125 Logger: logger,
118 LogCleanup: cleanup,126 LogCleanup: cleanup,
127+ LogPath: logPath,
119 }128 }
120 129
121- // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:130+ var persistOnce sync.Once
122- // if the first persist fails, we cache the error and return it on subsequent calls131+ var persistErr error
123- // rather than retrying. This prevents duplicate session records and ensures consistent132+
124- // error handling during shutdown sequences where persistFn may be called multiple times.
125 persistFn := func(persistCtx context.Context) error {133 persistFn := func(persistCtx context.Context) error {
126- persistMu.Do(func() {134+ persistOnce.Do(func() {
127- if lockDir != "" {135+ // Check for crash from previous run.
128- generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)136+ if logDir != "" {
129- if abruptErr != nil {137+ reports := detectPreviousCrash(logDir, &logger)
130- logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")138+ if len(reports) > 0 {
131- } else if len(generatedReports) > 0 {139+ bs.SetCrashReports(reports)
132- browserSession.SetUnexpectedCloseReports(generatedReports)
133 }140 }
134 }141 }
135 142
@@ -141,28 +148,15 @@ func StartBrowserSession(
141 148
142 logger.Info().149 logger.Info().
143 Str("session_id", string(session.ID)).150 Str("session_id", string(session.ID)).
144- Str("type", string(session.Type)).
145 Msg("session started")151 Msg("session started")
146- if lockDir != "" {
147- if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
148- logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
149- }
150- }
151 152
152- lf, lp, lockErr := lockSessionFile(lockDir, session.ID)153+ // Write PID file so next startup can detect crashes.
153- if lockErr != nil {154+ if logDir != "" {
154- // Don’t fail startup if locking fails; it only affects stale-session cleanup.155+ writePIDFile(logDir, &logger)
155- logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
156- } else {
157- lockMu.Lock()
158- lockFile = lf
159- lockPath = lp
160- lockMu.Unlock()
161 }156 }
162 157
163- // Defer stale session cleanup and old session pruning to background.158+ // Background cleanup of old sessions.
164- // This avoids blocking startup for non-critical housekeeping tasks.159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log)
165- runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
166 })160 })
167 return persistErr161 return persistErr
168 }162 }
@@ -171,29 +165,17 @@ func StartBrowserSession(
171 _ = persistFn(endCtx)165 _ = persistFn(endCtx)
172 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())166 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
173 167
174- lockMu.Lock()168+ // Remove PID file on clean shutdown.
175- lf := lockFile169+ if logDir != "" {
176- lp := lockPath170+ removePIDFile(logDir)
177- lockMu.Unlock()
178-
179- if lockDir != "" {
180- shutdownAt := time.Now().UTC()
181- if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
182- logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
183- }
184- }
185-
186- if lf != nil {
187- _ = unlockAndClose(lf)
188- _ = os.Remove(lp)
189 }171 }
190 return endErr172 return endErr
191 }173 }
192 174
193- browserSession.End = endFn175+ bs.End = endFn
194 176
195 if deferPersist {177 if deferPersist {
196- browserSession.Persist = persistFn178+ bs.Persist = persistFn
197 } else {179 } else {
198 if err := persistFn(ctx); err != nil {180 if err := persistFn(ctx); err != nil {
199 if cleanup != nil {181 if cleanup != nil {
@@ -203,479 +185,193 @@ func StartBrowserSession(
203 }185 }
204 }186 }
205 187
206- return browserSession, sessionCtx, nil188+ return bs, sessionCtx, nil
207 }189 }
208 190
209-func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {191+// ---------------------------------------------------------------------------
210- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)192+// PID file: single file {logDir}/dumber.pid
211- if abruptErr != nil {193+// ---------------------------------------------------------------------------
212- return nil, abruptErr
213- }
214-
215- generatedReports := make([]string, 0, len(abruptSessions))
216- for _, abruptSessionID := range abruptSessions {
217- if logger != nil {
218- logger.Warn().
219- Str("session_id", abruptSessionID).
220- Msg("abrupt-exit marker detected (startup without shutdown)")
221- }
222- reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223- if reportErr != nil {
224- if logger != nil {
225- logger.Warn().
226- Err(reportErr).
227- Str("session_id", abruptSessionID).
228- Msg("failed to write unexpected-close report")
229- }
230- continue
231- }
232- if reportPath == "" {
233- continue
234- }
235- generatedReports = append(generatedReports, reportPath)
236- if logger != nil {
237- logger.Warn().
238- Str("session_id", abruptSessionID).
239- Str("report_path", reportPath).
240- Msg("unexpected-close report generated")
241- }
242- }
243 194
244- return generatedReports, nil195+func pidFilePath(logDir string) string {
196+ return filepath.Join(logDir, pidFileName)
245 }197 }
246 198
247-func endStaleActiveBrowserSessions(199+func writePIDFile(logDir string, logger *zerolog.Logger) {
248- ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,200+ p := pidFilePath(logDir)
249-) error {201+ content := fmt.Sprintf("%d\n", os.Getpid())
250- // We only have GetRecentSessions; fetch enough to cover likely actives.202+ if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil {
251- recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)203+ logger.Warn().Err(err).Msg("failed to write PID file")
252- if err != nil {
253- return err
254 }204 }
255-
256- now := time.Now()
257- for _, s := range recent {
258- if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
259- continue
260- }
261-
262- lockPath := sessionLockPath(lockDir, s.ID)
263- if _, statErr := os.Stat(lockPath); statErr != nil {
264- // No lock file: conservative behavior, don’t auto-end.
265- continue
266- }
267-
268- f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
269- if err != nil {
270- continue
271- }
272-
273- locked, lockErr := tryLockExclusiveNonBlocking(f)
274- if lockErr != nil {
275- _ = f.Close()
276- continue
277- }
278- if !locked {
279- // Another process is holding the lock => session still alive.
280- _ = f.Close()
281- continue
282- }
283-
284- // We acquired the lock => no running process holds it => stale session.
285- if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
286- if log != nil {
287- log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
288- }
289- }
290- _ = unlockAndClose(f)
291- _ = os.Remove(lockPath)
292- }
293-
294- return nil
295 }205 }
296 206
297-func sessionLockPath(lockDir string, sessionID entity.SessionID) string {207+func removePIDFile(logDir string) {
298- return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))208+ _ = os.Remove(pidFilePath(logDir))
299 }209 }
300 210
301-func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {211+// detectPreviousCrash checks the PID file from a previous run.
302- if lockDir == "" {212+// If the file exists and the process is dead, the previous session crashed.
303- return nil, "", errors.New("lock dir is empty")213+// Returns generated crash report paths (if any).
304- }214+func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string {
305- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {215+ p := pidFilePath(logDir)
306- return nil, "", err216+ raw, err := os.ReadFile(p)
307- }
308-
309- lockPath := sessionLockPath(lockDir, sessionID)
310- f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
311 if err != nil {217 if err != nil {
312- return nil, "", err218+ return nil // No PID file → clean previous exit (or first run).
313- }
314-
315- locked, lockErr := tryLockExclusiveNonBlocking(f)
316- if lockErr != nil {
317- _ = f.Close()
318- return nil, "", lockErr
319- }
320- if !locked {
321- _ = f.Close()
322- return nil, "", errors.New("session lock already held")
323 }219 }
324- return f, lockPath, nil
325-}
326-
327-func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
328- if f == nil {
329- return false, errors.New("nil file")
330- }
331- err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
332- if err == nil {
333- return true, nil
334- }
335- if errors.Is(err, syscall.EWOULDBLOCK) {
336- return false, nil
337- }
338- return false, err
339-}
340 220
341-func unlockAndClose(f *os.File) error {221+ pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw)))
342- if f == nil {222+ if parseErr != nil {
223+ // Corrupt PID file, remove and move on.
224+ _ = os.Remove(p)
343 return nil225 return nil
344 }226 }
345- _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
346- return f.Close()
347-}
348-
349-func startupMarkerPath(lockDir, sessionID string) string {
350- return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
351-}
352-
353-func shutdownMarkerPath(lockDir, sessionID string) string {
354- return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
355-}
356-
357-func abruptMarkerPath(lockDir, sessionID string) string {
358- return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
359-}
360-
361-func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
362- if lockDir == "" || sessionID == "" {
363- return errors.New("startup marker requires lockDir and sessionID")
364- }
365- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
366- return err
367- }
368- content := []byte(fmt.Sprintf(
369- "%s\npid=%d\nppid=%d\n",
370- startedAt.Format(time.RFC3339Nano),
371- os.Getpid(),
372- os.Getppid(),
373- ))
374- if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
375- return err
376- }
377- _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
378- _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
379- return nil
380-}
381-
382-func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
383- if lockDir == "" || sessionID == "" {
384- return errors.New("shutdown marker requires lockDir and sessionID")
385- }
386- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
387- return err
388- }
389-
390- // Preserve the startup time inside the shutdown marker so
391- // ClassifySessionExitFromMarkers can still read it after the
392- // startup marker file is removed.
393- var startupLine string
394- var pidLine string
395- var ppidLine string
396- startupPath := startupMarkerPath(lockDir, sessionID)
397- if raw, readErr := os.ReadFile(startupPath); readErr == nil {
398- if t := firstNonEmptyLine(raw); t != "" {
399- startupLine = "started_at=" + t + "\n"
400- }
401- if pid := markerValue(raw, "pid="); pid != "" {
402- pidLine = "pid=" + pid + "\n"
403- }
404- if ppid := markerValue(raw, "ppid="); ppid != "" {
405- ppidLine = "ppid=" + ppid + "\n"
406- }
407- }
408-
409- content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
410- if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
411- return err
412- }
413- // Clean up the matching startup marker now that shutdown is recorded.
414- // Errors are non-fatal: the marker will be swept later if removal fails.
415- _ = os.Remove(startupPath)
416- return nil
417-}
418 227
419-func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {228+ // Check if the process is still running.
420- if lockDir == "" {229+ proc, procErr := os.FindProcess(pid)
421- return nil, nil230+ if procErr == nil {
422- }231+ // On Unix, FindProcess always succeeds. Send signal 0 to probe existence.
423- pattern := filepath.Join(lockDir, "session_*.startup.marker")232+ if killErr := proc.Signal(syscall.Signal(0)); killErr == nil {
424- startupMarkers, err := filepath.Glob(pattern)233+ // Process is alive → another instance running, don't touch PID file.
425- if err != nil {
426- return nil, err
427- }
428-
429- abruptSessions := make([]string, 0, len(startupMarkers))
430- for _, startupPath := range startupMarkers {
431- sessionID := sessionIDFromStartupMarker(startupPath)
432- if sessionID == "" {
433- continue
434- }
435-
436- shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
437- if checkErr != nil {
438 if logger != nil {234 if logger != nil {
439- logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")235+ logger.Warn().Int("pid", pid).Msg("previous instance still running (PID file exists)")
440 }236 }
441- continue237+ return nil
442- }
443- if !shouldMark {
444- continue
445 }238 }
446-
447- startupRaw, _ := os.ReadFile(startupPath)
448- payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449- if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450- payload += "started_at=" + startupAt + "\n"
451- }
452- if pid := markerValue(startupRaw, "pid="); pid != "" {
453- payload += "pid=" + pid + "\n"
454- }
455- if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456- payload += "ppid=" + ppid + "\n"
457- }
458-
459- if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
460- return abruptSessions, err
461- }
462- abruptSessions = append(abruptSessions, sessionID)
463 }239 }
464 240
465- return abruptSessions, nil241+ // Previous process is dead → crash. Generate report from last log.
466-}242+ _ = os.Remove(p)
467 243
468-func sessionIDFromStartupMarker(startupPath string) string {244+ lastLog := findLastSessionLog(logDir)
469- base := filepath.Base(startupPath)245+ if lastLog == "" {
470- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {246+ return nil
471- return ""
472- }
473- return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474-}
475-
476-func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477- active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478- if probeErr != nil {
479- return false, probeErr
480- }
481- if active {
482- return false, nil
483 }247 }
484 248
485- shutdownPath := shutdownMarkerPath(lockDir, sessionID)249+ lastSessionID := sessionIDFromLogPath(lastLog)
486- if exists, statErr := markerExists(shutdownPath); statErr != nil {250+ if lastSessionID == "" {
487- return false, statErr251+ return nil
488- } else if exists {
489- return false, nil
490 }252 }
491 253
492- abruptPath := abruptMarkerPath(lockDir, sessionID)254+ if logger != nil {
493- if exists, statErr := markerExists(abruptPath); statErr != nil {255+ logger.Warn().
494- return false, statErr256+ Int("crashed_pid", pid).
495- } else if exists {257+ Str("session_id", lastSessionID).
496- return false, nil258+ Msg("previous session crashed (stale PID file)")
497 }259 }
498 260
499- return true, nil261+ reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid)
500-}262+ if reportErr != nil {
501-
502-func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503- lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504- f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505- if openErr != nil {
506- if os.IsNotExist(openErr) {
507- return false, nil
508- }
509 if logger != nil {263 if logger != nil {
510- logger.Warn().264+ logger.Warn().Err(reportErr).Msg("failed to write crash report")
511- Err(openErr).
512- Str("session_id", sessionID).
513- Str("path", lockPath).
514- Msg("markAbruptExits: open lock file failed")
515 }265 }
516- return false, openErr266+ return nil
517- }
518-
519- locked, lockErr := tryLockExclusiveNonBlocking(f)
520- if lockErr != nil {
521- _ = f.Close()
522- if logger != nil {
523- logger.Warn().
524- Err(lockErr).
525- Str("session_id", sessionID).
526- Str("path", lockPath).
527- Msg("markAbruptExits: lock probe failed")
528- }
529- return false, lockErr
530 }267 }
531- if !locked {268+ if reportPath == "" {
532- _ = f.Close()269+ return nil
533- return true, nil
534 }270 }
535- _ = unlockAndClose(f)271+ return []string{reportPath}
536- return false, nil
537 }272 }
538 273
539-func markerExists(path string) (bool, error) {274+// findLastSessionLog returns the most recent session_*.log file in logDir.
540- _, err := os.Stat(path)275+func findLastSessionLog(logDir string) string {
541- if err == nil {276+ matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log"))
542- return true, nil277+ if err != nil || len(matches) == 0 {
278+ return ""
543 }279 }
544- if os.IsNotExist(err) {280+ var newest string
545- return false, nil281+ var newestMod time.Time
282+ for _, m := range matches {
283+ info, statErr := os.Stat(m)
284+ if statErr != nil {
285+ continue
286+ }
287+ if info.ModTime().After(newestMod) {
288+ newest = m
289+ newestMod = info.ModTime()
290+ }
546 }291 }
547- return false, err292+ return newest
548 }293 }
549 294
550-func firstNonEmptyLine(raw []byte) string {295+func sessionIDFromLogPath(logPath string) string {
551- trimmed := strings.TrimSpace(string(raw))296+ base := filepath.Base(logPath)
552- if trimmed == "" {297+ id, ok := corelogging.ParseSessionFilename(base)
298+ if !ok {
553 return ""299 return ""
554 }300 }
555- first, _, _ := strings.Cut(trimmed, "\n")301+ return id
556- return strings.TrimSpace(first)
557 }302 }
558 303
559-func markerValue(raw []byte, key string) string {304+// ---------------------------------------------------------------------------
560- if key == "" || len(raw) == 0 {305+// Background cleanup
561- return ""306+// ---------------------------------------------------------------------------
562- }
563- for _, line := range strings.Split(string(raw), "\n") {
564- line = strings.TrimSpace(line)
565- if strings.HasPrefix(line, key) {
566- return strings.TrimSpace(strings.TrimPrefix(line, key))
567- }
568- }
569- return ""
570-}
571 307
572-// runSessionCleanupAsync performs stale session cleanup and old session pruning308+func runSessionCleanup(
573-// in a background goroutine. This avoids blocking startup for non-critical tasks.
574-// Uses context.WithoutCancel(startupCtx) so the background work inherits request
575-// values and the logger from the startup context while detaching from its cancellation.
576-func runSessionCleanupAsync(
577 startupCtx context.Context,309 startupCtx context.Context,
578 sessionUC *usecase.ManageSessionUseCase,310 sessionUC *usecase.ManageSessionUseCase,
579 cleanupUC *usecase.CleanupSessionsUseCase,311 cleanupUC *usecase.CleanupSessionsUseCase,
580 cfg *config.Config,312 cfg *config.Config,
581- lockDir string,313+ currentSessionID entity.SessionID,
582 log *zerolog.Logger,314 log *zerolog.Logger,
583 ) {315 ) {
584- go func() {316+ bgCtx := context.WithoutCancel(startupCtx)
585- // Detach cancellation from the startup context so cleanup runs to completion
586- // even if the startup context is canceled, while still carrying its values/logger.
587- bgCtx := context.WithoutCancel(startupCtx)
588-
589- // End stale active sessions (orphaned from crashed processes)
590- if lockDir != "" {
591- if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
592- if log != nil {
593- log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
594- }
595- }
596- }
597 317
598- // Clean up old exited sessions based on config limits318+ // End stale active sessions that have no running process.
599- cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{319+ recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)
600- MaxExitedSessions: cfg.Session.MaxExitedSessions,320+ if err != nil {
601- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,321+ if log != nil {
602- })322+ log.Warn().Err(err).Msg("background: failed to list recent sessions")
603- if err != nil {
604- if log != nil {
605- log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
606- }
607- } else if cleanupOutput.TotalDeleted > 0 {
608- if log != nil {
609- log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
610- }
611 }323 }
612-324+ } else {
613- // Sweep paired startup+shutdown markers that are no longer needed.325+ now := time.Now()
614- if lockDir != "" {326+ for _, s := range recent {
615- sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)327+ if s != nil &&
328+ s.ID != currentSessionID &&
329+ s.Type == entity.SessionTypeBrowser &&
330+ s.IsActive() {
331+ _ = sessionUC.EndSession(bgCtx, s.ID, now)
332+ }
616 }333 }
617- }()
618-}
619-
620-// sweepPairedMarkers removes marker files for sessions that have both a
621-// shutdown marker and a startup marker older than maxAgeDays. Both markers
622-// must exist and be aged past the cutoff before any files are removed.
623-// The associated abrupt marker (if present) is also cleaned up.
624-// Removal errors are non-fatal (logged and skipped).
625-func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
626- if lockDir == "" {
627- return
628- }
629- if maxAgeDays <= 0 {
630- maxAgeDays = 7
631 }334 }
632- cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
633 335
634- shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))336+ // Clean up old exited sessions.
337+ output, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
338+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
339+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
340+ })
635 if err != nil {341 if err != nil {
636 if log != nil {342 if log != nil {
637- log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")343+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
638 }344 }
639- return345+ } else if output.TotalDeleted > 0 && log != nil {
346+ log.Info().Int64("deleted", output.TotalDeleted).Msg("background: cleaned up old sessions")
640 }347 }
641 348
642- swept := 0349+ // Prune old marker files left by previous versions.
643- for _, shutdownPath := range shutdownMarkers {350+ sweepLegacyMarkers(cfg.Logging.LogDir, log)
644- base := filepath.Base(shutdownPath)351+}
645- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
646- continue
647- }
648- sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
649- if sessionID == "" {
650- continue
651- }
652-
653- shutdownInfo, err := os.Stat(shutdownPath)
654- if err != nil || shutdownInfo.ModTime().After(cutoff) {
655- continue
656- }
657-
658- startupPath := startupMarkerPath(lockDir, sessionID)
659- startupInfo, err := os.Stat(startupPath)
660- if err != nil || startupInfo.ModTime().After(cutoff) {
661- // Startup marker missing or too recent — skip this session.
662- continue
663- }
664-
665- abruptPath := abruptMarkerPath(lockDir, sessionID)
666 352
667- // Remove the trio: shutdown, startup, abrupt (if present).353+// sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
668- for _, p := range []string{shutdownPath, startupPath, abruptPath} {354+// .abrupt.marker, and .lock files left by the old session system.
669- if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {355+func sweepLegacyMarkers(logDir string, log *zerolog.Logger) {
670- if log != nil {356+ if logDir == "" {
671- log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")357+ return
672- }358+ }
359+ patterns := []string{
360+ "session_*.startup.marker",
361+ "session_*.shutdown.marker",
362+ "session_*.abrupt.marker",
363+ "session_*.lock",
364+ }
365+ swept := 0
366+ for _, pat := range patterns {
367+ matches, _ := filepath.Glob(filepath.Join(logDir, pat))
368+ for _, m := range matches {
369+ if os.Remove(m) == nil {
370+ swept++
673 }371 }
674 }372 }
675- swept++
676 }373 }
677-
678 if swept > 0 && log != nil {374 if swept > 0 && log != nil {
679- log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")375+ log.Info().Int("swept", swept).Msg("cleaned up legacy session marker files")
680 }376 }
681 }377 }
D internal/bootstrap/session_exit_classification.go +0 -209
@@ -1,209 +0,0 @@
1-package bootstrap
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7- "sort"
8- "strings"
9- "time"
10-)
11-
12-type SessionExitClass string
13-
14-const (
15- SessionExitCleanExit SessionExitClass = "clean_exit"
16- SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt"
17- SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred"
18- SessionExitUnknown SessionExitClass = "unknown"
19-)
20-
21-type SessionExitClassification struct {
22- SessionID string `json:"session_id"`
23- Class SessionExitClass `json:"class"`
24- Inference string `json:"inference"`
25- Reason string `json:"reason"`
26- StartupAt *time.Time `json:"startup_at,omitempty"`
27- ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28- AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29- LastMarkerObserved time.Time `json:"last_marker_observed"`
30-}
31-
32-func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
33- classification := SessionExitClassification{
34- SessionID: sessionID,
35- Class: SessionExitUnknown,
36- Inference: "marker-missing",
37- Reason: "no known marker files found for session",
38- }
39- if lockDir == "" || sessionID == "" {
40- return classification, fmt.Errorf("lockDir and sessionID are required")
41- }
42-
43- startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "")
44- if err != nil {
45- return classification, err
46- }
47- shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "")
48- if err != nil {
49- return classification, err
50- }
51- abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=")
52- if err != nil {
53- return classification, err
54- }
55-
56- // If the startup marker was already cleaned up (writeShutdownMarker removes
57- // it), fall back to the started_at line embedded in the shutdown marker.
58- if startupAt == nil && shutdownExists {
59- startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=")
60- }
61- classification.StartupAt = startupAt
62- classification.ShutdownAt = shutdownAt
63- classification.AbruptDetectedAt = abruptAt
64- classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
65-
66- switch {
67- case shutdownExists:
68- classification.Class = SessionExitCleanExit
69- classification.Inference = "marker-confirmed"
70- classification.Reason = "shutdown marker present"
71- case abruptExists:
72- classification.Class = SessionExitMainProcessCrashOrAbrupt
73- classification.Inference = "marker-confirmed"
74- classification.Reason = "abrupt marker present and no shutdown marker"
75- case startupExists:
76- classification.Class = SessionExitExternalKillOrOOMInferred
77- classification.Inference = "best-effort"
78- classification.Reason = "startup marker present without shutdown/abrupt markers"
79- }
80-
81- return classification, nil
82-}
83-
84-func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
85- if lockDir == "" {
86- return nil, nil
87- }
88-
89- sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
90- if err != nil {
91- return nil, err
92- }
93- if len(sessionIDs) == 0 {
94- return nil, nil
95- }
96-
97- report := make([]SessionExitClassification, 0, len(sessionIDs))
98- for _, sessionID := range sessionIDs {
99- classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
100- if classErr != nil {
101- return nil, classErr
102- }
103- report = append(report, classification)
104- }
105-
106- sort.Slice(report, func(i, j int) bool {
107- return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
108- })
109- return report, nil
110-}
111-
112-func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
113- pattern := filepath.Join(lockDir, "session_*.marker")
114- paths, err := filepath.Glob(pattern)
115- if err != nil {
116- return nil, err
117- }
118-
119- ids := make(map[string]struct{}, len(paths))
120- for _, path := range paths {
121- base := filepath.Base(path)
122- sessionID, ok := markerSessionID(base)
123- if !ok {
124- continue
125- }
126- ids[sessionID] = struct{}{}
127- }
128-
129- sessionIDs := make([]string, 0, len(ids))
130- for id := range ids {
131- sessionIDs = append(sessionIDs, id)
132- }
133- sort.Strings(sessionIDs)
134- return sessionIDs, nil
135-}
136-
137-func markerSessionID(base string) (string, bool) {
138- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
139- return "", false
140- }
141-
142- trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
143- switch {
144- case strings.HasSuffix(trimmed, ".startup"):
145- return strings.TrimSuffix(trimmed, ".startup"), true
146- case strings.HasSuffix(trimmed, ".shutdown"):
147- return strings.TrimSuffix(trimmed, ".shutdown"), true
148- case strings.HasSuffix(trimmed, ".abrupt"):
149- return strings.TrimSuffix(trimmed, ".abrupt"), true
150- default:
151- return "", false
152- }
153-}
154-
155-func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
156- stat, err := os.Stat(path)
157- if err != nil {
158- if os.IsNotExist(err) {
159- return nil, false, time.Time{}, nil
160- }
161- return nil, false, time.Time{}, err
162- }
163-
164- content, err := os.ReadFile(path)
165- if err != nil {
166- return nil, true, stat.ModTime(), err
167- }
168-
169- raw := strings.TrimSpace(string(content))
170- if raw == "" {
171- return nil, true, stat.ModTime(), nil
172- }
173-
174- if keyPrefix != "" {
175- lines := strings.Split(raw, "\n")
176- for _, line := range lines {
177- if strings.HasPrefix(line, keyPrefix) {
178- parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
179- if parseErr != nil {
180- return nil, true, stat.ModTime(), nil
181- }
182- return &parsed, true, stat.ModTime(), nil
183- }
184- }
185- return nil, true, stat.ModTime(), nil
186- }
187-
188- // When no key prefix is given, parse the first line as the timestamp.
189- // Multi-line markers may contain additional metadata (e.g. started_at=).
190- firstLine, _, _ := strings.Cut(raw, "\n")
191- parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine)
192- if parseErr != nil {
193- return nil, true, stat.ModTime(), nil
194- }
195- return &parsed, true, stat.ModTime(), nil
196-}
197-
198-func latestNonZero(values ...time.Time) time.Time {
199- var latest time.Time
200- for _, value := range values {
201- if value.IsZero() {
202- continue
203- }
204- if latest.IsZero() || value.After(latest) {
205- latest = value
206- }
207- }
208- return latest
209-}
D internal/bootstrap/session_exit_classification_test.go +0 -115
@@ -1,115 +0,0 @@
1-package bootstrap
2-
3-import (
4- "os"
5- "path/filepath"
6- "testing"
7- "time"
8-
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) {
14- lockDir := t.TempDir()
15- sessionID := "clean-1"
16- startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC)
17- endedAt := startedAt.Add(2 * time.Minute)
18-
19- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
20- require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt))
21-
22- classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
23- require.NoError(t, err)
24- assert.Equal(t, SessionExitCleanExit, classification.Class)
25- assert.Equal(t, "marker-confirmed", classification.Inference)
26- assert.Equal(t, "shutdown marker present", classification.Reason)
27- require.NotNil(t, classification.StartupAt)
28- assert.Equal(t, startedAt, *classification.StartupAt)
29- require.NotNil(t, classification.ShutdownAt)
30- assert.Equal(t, endedAt, *classification.ShutdownAt)
31- assert.Nil(t, classification.AbruptDetectedAt)
32-}
33-
34-func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) {
35- lockDir := t.TempDir()
36- sessionID := "abrupt-1"
37- startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
38- detectedAt := startedAt.Add(5 * time.Minute)
39-
40- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
41- require.NoError(t, os.WriteFile(
42- abruptMarkerPath(lockDir, sessionID),
43- []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"),
44- markerFilePerm,
45- ))
46-
47- classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
48- require.NoError(t, err)
49- assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class)
50- assert.Equal(t, "marker-confirmed", classification.Inference)
51- assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason)
52- require.NotNil(t, classification.StartupAt)
53- assert.Equal(t, startedAt, *classification.StartupAt)
54- assert.Nil(t, classification.ShutdownAt)
55- require.NotNil(t, classification.AbruptDetectedAt)
56- assert.Equal(t, detectedAt, *classification.AbruptDetectedAt)
57-}
58-
59-func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) {
60- lockDir := t.TempDir()
61- sessionID := "inferred-1"
62- startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
63-
64- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
65-
66- classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
67- require.NoError(t, err)
68- assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class)
69- assert.Equal(t, "best-effort", classification.Inference)
70- assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason)
71- require.NotNil(t, classification.StartupAt)
72- assert.Equal(t, startedAt, *classification.StartupAt)
73- assert.Nil(t, classification.ShutdownAt)
74- assert.Nil(t, classification.AbruptDetectedAt)
75-}
76-
77-func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) {
78- _, err := ClassifySessionExitFromMarkers("", "s1")
79- require.Error(t, err)
80-
81- _, err = ClassifySessionExitFromMarkers(t.TempDir(), "")
82- require.Error(t, err)
83-}
84-
85-func TestBuildSessionExitReport(t *testing.T) {
86- lockDir := t.TempDir()
87-
88- require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC)))
89- require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC)))
90-
91- require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC)))
92- require.NoError(t, os.WriteFile(
93- abruptMarkerPath(lockDir, "abrupt-r"),
94- []byte("detected_at=2026-02-07T08:08:00Z\n"),
95- markerFilePerm,
96- ))
97-
98- require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC)))
99-
100- // Unknown marker type should not be included.
101- require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm))
102-
103- report, err := BuildSessionExitReport(lockDir)
104- require.NoError(t, err)
105- require.Len(t, report, 3)
106-
107- got := map[string]SessionExitClass{}
108- for _, item := range report {
109- got[item.SessionID] = item.Class
110- }
111-
112- assert.Equal(t, SessionExitCleanExit, got["clean-r"])
113- assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"])
114- assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"])
115-}
D internal/bootstrap/session_markers_test.go +0 -73
@@ -1,73 +0,0 @@
1-package bootstrap
2-
3-import (
4- "os"
5- "path/filepath"
6- "testing"
7- "time"
8-
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
14- lockDir := t.TempDir()
15- sessionID := "s123"
16- startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
17- endedAt := startedAt.Add(3 * time.Minute)
18-
19- err := writeStartupMarker(lockDir, sessionID, startedAt)
20- require.NoError(t, err)
21-
22- startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
23- require.NoError(t, err)
24- assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25- assert.Contains(t, string(startupContent), "pid=")
26- assert.Contains(t, string(startupContent), "ppid=")
27-
28- err = writeShutdownMarker(lockDir, sessionID, endedAt)
29- require.NoError(t, err)
30-
31- shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
32- require.NoError(t, err)
33- assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
34- assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano))
35- assert.Contains(t, string(shutdownContent), "pid=")
36- assert.Contains(t, string(shutdownContent), "ppid=")
37-}
38-
39-func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
40- lockDir := t.TempDir()
41- detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
42-
43- require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
44- require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
45- require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
46-
47- abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
48- require.NoError(t, err)
49- assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
50-
51- abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
52- require.NoError(t, err)
53- assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
54- assert.Contains(t, string(abruptMarkerData), "started_at=")
55- assert.Contains(t, string(abruptMarkerData), "pid=")
56- assert.Contains(t, string(abruptMarkerData), "ppid=")
57- assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
58-}
59-
60-func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
61- lockDir := t.TempDir()
62- detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
63-
64- require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
65-
66- first, err := markAbruptExits(lockDir, detectedAt, nil)
67- require.NoError(t, err)
68- assert.Equal(t, []string{"abrupt-c"}, first)
69-
70- second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
71- require.NoError(t, err)
72- assert.Empty(t, second)
73-}
A internal/bootstrap/session_test.go +67 -0
@@ -0,0 +1,67 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
17+ t.Parallel()
18+
19+ ctx := context.Background()
20+ repo := repomocks.NewMockSessionRepository(t)
21+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
22+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
23+ cfg := config.DefaultConfig()
24+
25+ now := time.Now().UTC()
26+ current := &entity.Session{
27+ ID: entity.SessionID("current"),
28+ Type: entity.SessionTypeBrowser,
29+ StartedAt: now,
30+ }
31+ stale := &entity.Session{
32+ ID: entity.SessionID("stale"),
33+ Type: entity.SessionTypeBrowser,
34+ StartedAt: now.Add(-time.Hour),
35+ }
36+
37+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, stale}, nil).Once()
38+ repo.EXPECT().MarkEnded(mock.Anything, stale.ID, mock.MatchedBy(func(t time.Time) bool {
39+ diff := t.Sub(now)
40+ if diff < 0 {
41+ diff = -diff
42+ }
43+ return diff < 2*time.Second
44+ })).Return(nil).Once()
45+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
46+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
47+
48+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, current.ID, nil)
49+}
50+
51+func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
52+ t.Parallel()
53+
54+ ctx := context.Background()
55+ repo := repomocks.NewMockSessionRepository(t)
56+ sessionUC := usecase.NewManageSessionUseCase(repo, nil)
57+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
58+ cfg := config.DefaultConfig()
59+
60+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()
61+ repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
62+ repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
63+
64+ require.NotPanics(t, func() {
65+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, entity.SessionID("current"), nil)
66+ })
67+}
M internal/cli/app.go +2 -2
@@ -112,9 +112,9 @@ func NewApp() (*App, error) {
112 112
113 // Create use cases113 // Create use cases
114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115- listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)115+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117- deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)117+ deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
118 118
119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
120 faviconCacheDir, _ := config.GetFaviconCacheDir()120 faviconCacheDir, _ := config.GetFaviconCacheDir()
M internal/domain/service/favicon.go +2 -2
@@ -12,10 +12,10 @@ type FaviconService interface {
12 12
13 // GetCached returns favicon bytes only if already cached (no external fetch).13 // GetCached returns favicon bytes only if already cached (no external fetch).
14 // Returns the bytes and true if found, nil and false otherwise.14 // Returns the bytes and true if found, nil and false otherwise.
15- GetCached(domain string) ([]byte, bool)15+ GetCached(ctx context.Context, domain string) ([]byte, bool)
16 16
17 // Store saves favicon bytes for a domain to cache.17 // Store saves favicon bytes for a domain to cache.
18- Store(domain string, data []byte) error18+ Store(ctx context.Context, domain string, data []byte) error
19 19
20 // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).20 // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
21 DiskPath(domain string) string21 DiskPath(domain string) string
A internal/infrastructure/audio/factory/output.go +65 -0
@@ -0,0 +1,65 @@
1+// Package factory provides audio backend selection with fallback support.
2+package factory
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/infrastructure/audio/null"
11+ "github.com/bnema/dumber/internal/infrastructure/audio/pipewire"
12+)
13+
14+// Selector chooses between a primary and fallback audio backend.
15+// If the primary backend fails to create a stream, it falls back to the fallback.
16+type Selector struct {
17+ Primary port.AudioOutputFactory
18+ Fallback port.AudioOutputFactory
19+}
20+
21+// NewStream attempts to create a stream from the primary backend.
22+// If that fails, it falls back to the fallback backend.
23+// Returns an error only if both backends fail.
24+func (s *Selector) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
25+ if s.Primary != nil {
26+ stream, err := s.Primary.NewStream(ctx, format)
27+ if err == nil {
28+ return stream, nil
29+ }
30+
31+ // Primary failed, try fallback
32+ if s.Fallback != nil {
33+ fallbackStream, fallbackErr := s.Fallback.NewStream(ctx, format)
34+ if fallbackErr == nil {
35+ return fallbackStream, nil
36+ }
37+ return nil, fmt.Errorf("audio backends failed: %w", errors.Join(err, fallbackErr))
38+ }
39+ return nil, fmt.Errorf("primary failed: %w; no fallback configured", err)
40+ }
41+
42+ // No primary, try fallback only
43+ if s.Fallback != nil {
44+ return s.Fallback.NewStream(ctx, format)
45+ }
46+
47+ return nil, errors.New("no audio backend configured")
48+}
49+
50+// NewAudioOutputFactory creates the primary audio output factory with PipeWire
51+// as the primary backend and null as the fallback.
52+// If PipeWire stream creation fails at runtime, the Selector falls back to the
53+// null backend automatically.
54+func NewAudioOutputFactory() port.AudioOutputFactory {
55+ pwFactory, err := pipewire.NewFactory()
56+ if err != nil {
57+ // Unexpected failure; use null fallback only
58+ return &null.Factory{}
59+ }
60+
61+ return &Selector{
62+ Primary: pwFactory,
63+ Fallback: &null.Factory{},
64+ }
65+}
A internal/infrastructure/audio/factory/output_test.go +172 -0
@@ -0,0 +1,172 @@
1+package factory
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/infrastructure/audio/null"
10+)
11+
12+// failingFactory is a factory that always returns an error
13+type failingFactory struct {
14+ err error
15+}
16+
17+func (f *failingFactory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) {
18+ return nil, f.err
19+}
20+
21+func TestSelector_PrimaryFails_FallsBackToFallback(t *testing.T) {
22+ // Arrange
23+ primaryErr := errors.New("primary backend unavailable")
24+ primary := &failingFactory{err: primaryErr}
25+ fallback := &null.Factory{}
26+
27+ selector := &Selector{
28+ Primary: primary,
29+ Fallback: fallback,
30+ }
31+
32+ format := port.AudioStreamFormat{
33+ SampleRate: 48000,
34+ ChannelCount: 2,
35+ FramesPerBuffer: 512,
36+ }
37+ ctx := context.Background()
38+
39+ // Act
40+ stream, err := selector.NewStream(ctx, format)
41+
42+ // Assert
43+ if err != nil {
44+ t.Fatalf("Expected no error when fallback succeeds, got: %v", err)
45+ }
46+ if stream == nil {
47+ t.Fatal("Expected non-nil stream from fallback")
48+ }
49+
50+ // Verify the stream works (it's the null stream)
51+ samples := make([][]float32, format.ChannelCount)
52+ for i := range samples {
53+ samples[i] = make([]float32, format.FramesPerBuffer)
54+ }
55+
56+ if err := stream.Write(samples); err != nil {
57+ t.Errorf("Write failed: %v", err)
58+ }
59+ if err := stream.Close(); err != nil {
60+ t.Errorf("Close failed: %v", err)
61+ }
62+}
63+
64+func TestSelector_PrimarySucceeds_UsesPrimary(t *testing.T) {
65+ // Arrange
66+ primary := &null.Factory{} // Using null as primary for simplicity
67+ fallback := &failingFactory{err: errors.New("fallback should not be called")}
68+
69+ selector := &Selector{
70+ Primary: primary,
71+ Fallback: fallback,
72+ }
73+
74+ format := port.AudioStreamFormat{
75+ SampleRate: 48000,
76+ ChannelCount: 2,
77+ FramesPerBuffer: 512,
78+ }
79+ ctx := context.Background()
80+
81+ // Act
82+ stream, err := selector.NewStream(ctx, format)
83+
84+ // Assert
85+ if err != nil {
86+ t.Fatalf("Expected no error, got: %v", err)
87+ }
88+ if stream == nil {
89+ t.Fatal("Expected non-nil stream from primary")
90+ }
91+
92+ // Verify the stream works
93+ samples := make([][]float32, format.ChannelCount)
94+ for i := range samples {
95+ samples[i] = make([]float32, format.FramesPerBuffer)
96+ }
97+
98+ if err := stream.Write(samples); err != nil {
99+ t.Errorf("Write failed: %v", err)
100+ }
101+ if err := stream.Close(); err != nil {
102+ t.Errorf("Close failed: %v", err)
103+ }
104+}
105+
106+func TestSelector_BothFail_ReturnsError(t *testing.T) {
107+ // Arrange
108+ primaryErr := errors.New("primary backend unavailable")
109+ fallbackErr := errors.New("fallback backend unavailable")
110+
111+ selector := &Selector{
112+ Primary: &failingFactory{err: primaryErr},
113+ Fallback: &failingFactory{err: fallbackErr},
114+ }
115+
116+ format := port.AudioStreamFormat{
117+ SampleRate: 48000,
118+ ChannelCount: 2,
119+ FramesPerBuffer: 512,
120+ }
121+ ctx := context.Background()
122+
123+ // Act
124+ stream, err := selector.NewStream(ctx, format)
125+
126+ // Assert
127+ if err == nil {
128+ t.Fatal("Expected error when both backends fail")
129+ }
130+ if stream != nil {
131+ t.Error("Expected nil stream when both backends fail")
132+ }
133+}
134+
135+// TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that
136+// NewAudioOutputFactory returns a working Selector with PipeWire primary
137+// and null fallback.
138+func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) {
139+ factory := NewAudioOutputFactory()
140+ if factory == nil {
141+ t.Fatal("Expected non-nil factory")
142+ }
143+
144+ // Verify the factory produces a working stream
145+ format := port.AudioStreamFormat{
146+ SampleRate: 48000,
147+ ChannelCount: 2,
148+ FramesPerBuffer: 512,
149+ }
150+ ctx := context.Background()
151+
152+ stream, err := factory.NewStream(ctx, format)
153+ if err != nil {
154+ t.Fatalf("Expected no error creating stream, got: %v", err)
155+ }
156+ if stream == nil {
157+ t.Fatal("Expected non-nil stream")
158+ }
159+
160+ // Verify the stream works
161+ samples := make([][]float32, format.ChannelCount)
162+ for i := range samples {
163+ samples[i] = make([]float32, format.FramesPerBuffer)
164+ }
165+
166+ if err := stream.Write(samples); err != nil {
167+ t.Errorf("Write failed: %v", err)
168+ }
169+ if err := stream.Close(); err != nil {
170+ t.Errorf("Close failed: %v", err)
171+ }
172+}
A internal/infrastructure/audio/null/output.go +33 -0
@@ -0,0 +1,33 @@
1+// Package null provides a null audio backend that discards all audio data.
2+// This is useful as a fallback when no real audio backend is available.
3+package null
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// Factory creates null audio output streams that discard all audio data.
12+type Factory struct{}
13+
14+// NewStream creates a new null audio output stream.
15+// The stream accepts writes but discards all data silently.
16+func (*Factory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) {
17+ return &Stream{}, nil
18+}
19+
20+// Stream is a null audio output stream that discards all audio data.
21+type Stream struct{}
22+
23+// Write accepts audio samples but discards them silently.
24+// This method never returns an error.
25+func (*Stream) Write(_ [][]float32) error {
26+ return nil
27+}
28+
29+// Close releases the audio stream.
30+// This method is idempotent and never returns an error.
31+func (*Stream) Close() error {
32+ return nil
33+}
A internal/infrastructure/audio/null/output_test.go +96 -0
@@ -0,0 +1,96 @@
1+package null
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+func TestFactory_NewStream_ReturnsWritableClosableStream(t *testing.T) {
11+ // Arrange
12+ factory := &Factory{}
13+ format := port.AudioStreamFormat{
14+ SampleRate: 48000,
15+ ChannelCount: 2,
16+ FramesPerBuffer: 512,
17+ }
18+ ctx := context.Background()
19+
20+ // Act
21+ stream, err := factory.NewStream(ctx, format)
22+ if err != nil {
23+ t.Fatalf("NewStream failed: %v", err)
24+ }
25+ if stream == nil {
26+ t.Fatal("NewStream returned nil stream")
27+ }
28+
29+ // Assert: stream should accept writes
30+ samples := make([][]float32, format.ChannelCount)
31+ for i := range samples {
32+ samples[i] = make([]float32, format.FramesPerBuffer)
33+ }
34+
35+ if err := stream.Write(samples); err != nil {
36+ t.Errorf("Write failed: %v", err)
37+ }
38+
39+ // Assert: stream should be closable
40+ if err := stream.Close(); err != nil {
41+ t.Errorf("Close failed: %v", err)
42+ }
43+}
44+
45+func TestStream_Write_AcceptsMultipleCalls(t *testing.T) {
46+ // Arrange
47+ factory := &Factory{}
48+ format := port.AudioStreamFormat{
49+ SampleRate: 44100,
50+ ChannelCount: 2,
51+ FramesPerBuffer: 1024,
52+ }
53+ ctx := context.Background()
54+
55+ stream, err := factory.NewStream(ctx, format)
56+ if err != nil {
57+ t.Fatalf("NewStream failed: %v", err)
58+ }
59+ defer stream.Close()
60+
61+ samples := make([][]float32, format.ChannelCount)
62+ for i := range samples {
63+ samples[i] = make([]float32, format.FramesPerBuffer)
64+ }
65+
66+ // Act & Assert: multiple writes should succeed
67+ for i := 0; i < 3; i++ {
68+ if err := stream.Write(samples); err != nil {
69+ t.Errorf("Write #%d failed: %v", i+1, err)
70+ }
71+ }
72+}
73+
74+func TestStream_Close_Idempotent(t *testing.T) {
75+ // Arrange
76+ factory := &Factory{}
77+ format := port.AudioStreamFormat{
78+ SampleRate: 48000,
79+ ChannelCount: 1,
80+ FramesPerBuffer: 256,
81+ }
82+ ctx := context.Background()
83+
84+ stream, err := factory.NewStream(ctx, format)
85+ if err != nil {
86+ t.Fatalf("NewStream failed: %v", err)
87+ }
88+
89+ // Act & Assert: multiple closes should succeed
90+ if err := stream.Close(); err != nil {
91+ t.Errorf("First Close failed: %v", err)
92+ }
93+ if err := stream.Close(); err != nil {
94+ t.Errorf("Second Close failed: %v", err)
95+ }
96+}
A internal/infrastructure/audio/pipewire/output.go +280 -0
@@ -0,0 +1,280 @@
1+// Package pipewire provides a PipeWire-based audio output implementation.
2+// This wraps purego-pipewire's high-level Player API.
3+package pipewire
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "sync/atomic"
11+
12+ pwpipewire "github.com/bnema/purego-pipewire/pipewire"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/logging"
16+)
17+
18+// Compile-time interface checks.
19+var (
20+ _ port.AudioOutputFactory = (*Factory)(nil)
21+ _ port.AudioOutputStream = (*playerStreamAdapter)(nil)
22+)
23+
24+// ErrInvalidFormat is returned when the audio stream format is invalid.
25+var ErrInvalidFormat = errors.New("invalid audio stream format")
26+
27+// ErrStreamClosed is returned when writing to a closed stream.
28+var ErrStreamClosed = errors.New("audio stream closed")
29+
30+// playerCreator is a function type that creates a Player from config and callbacks.
31+// This exists to allow dependency injection in tests.
32+type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error)
33+
34+// Factory creates PipeWire audio output streams.
35+// Each call to NewStream creates an independent purego-pipewire Player.
36+type Factory struct {
37+ createPlayer playerCreator
38+}
39+
40+// NewFactory creates a new PipeWire audio factory.
41+// The factory is stateless; players are created per-stream.
42+func NewFactory() (*Factory, error) {
43+ return &Factory{
44+ createPlayer: pwpipewire.NewPlayer,
45+ }, nil
46+}
47+
48+// NewStream creates a new audio output stream with the given format.
49+// It validates the Dumber format, creates a purego-pipewire Player configured
50+// from the format, and returns a push-to-pull adapter that bridges Write()
51+// calls into the Player's Fill callback.
52+func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
53+ // Check context cancellation
54+ if ctx.Err() != nil {
55+ return nil, ctx.Err()
56+ }
57+
58+ // Validate format
59+ if err := validateFormat(format); err != nil {
60+ return nil, err
61+ }
62+
63+ log := logging.FromContext(ctx)
64+ log.Info().
65+ Int("sample_rate", format.SampleRate).
66+ Int("channels", format.ChannelCount).
67+ Int("frames_per_buffer", format.FramesPerBuffer).
68+ Msg("pipewire: creating player")
69+
70+ // Create the adapter with its ring buffer before creating the player,
71+ // because the Fill callback captures the adapter.
72+ adapter := &playerStreamAdapter{
73+ ctx: ctx,
74+ buf: make(chan [][]float32, 4), // small buffer for push→pull bridging
75+ closed: make(chan struct{}),
76+ }
77+
78+ config := pwpipewire.PlayerConfig{
79+ SampleRate: format.SampleRate,
80+ Channels: format.ChannelCount,
81+ FramesPerBuffer: format.FramesPerBuffer,
82+ SampleFormat: pwpipewire.SampleFormatF32,
83+ UnderrunPolicy: pwpipewire.UnderrunFillSilence,
84+ }
85+
86+ callbacks := pwpipewire.PlayerCallbacks{
87+ Fill: adapter.fill,
88+ }
89+
90+ player, err := f.createPlayer(config, callbacks)
91+ if err != nil {
92+ log.Warn().Err(err).Msg("pipewire: failed to create player")
93+ return nil, fmt.Errorf("failed to create pipewire player: %w", err)
94+ }
95+ adapter.player = player
96+
97+ // Start the player so the Fill callback begins being called.
98+ if err := player.Start(); err != nil {
99+ _ = player.Close()
100+ log.Warn().Err(err).Msg("pipewire: failed to start player")
101+ return nil, fmt.Errorf("failed to start pipewire player: %w", err)
102+ }
103+
104+ log.Info().Msg("pipewire: player started successfully")
105+
106+ return adapter, nil
107+}
108+
109+// validateFormat checks if the audio format parameters are valid.
110+func validateFormat(format port.AudioStreamFormat) error {
111+ // Sample rate must be reasonable (8kHz to 384kHz)
112+ if format.SampleRate < 8000 || format.SampleRate > 384000 {
113+ return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]",
114+ ErrInvalidFormat, format.SampleRate)
115+ }
116+
117+ // Channel count must be 1 to 1024 (reasonable upper limit)
118+ if format.ChannelCount < 1 || format.ChannelCount > 1024 {
119+ return fmt.Errorf("%w: channel count %d out of range [1, 1024]",
120+ ErrInvalidFormat, format.ChannelCount)
121+ }
122+
123+ // Frames per buffer must be positive and reasonable
124+ if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 {
125+ return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]",
126+ ErrInvalidFormat, format.FramesPerBuffer)
127+ }
128+
129+ return nil
130+}
131+
132+// playerStreamAdapter bridges Dumber's push-style Write(samples) interface
133+// to purego-pipewire's pull-style Fill callback.
134+//
135+// Write() sends sample buffers into a channel. The Fill callback drains them
136+// into the PCMBuffer that purego-pipewire passes in. If no data is available,
137+// the player's UnderrunFillSilence policy handles it.
138+type playerStreamAdapter struct {
139+ player pwpipewire.Player
140+ ctx context.Context
141+ buf chan [][]float32 // push→pull handoff channel
142+
143+ closeOnce sync.Once
144+ closed chan struct{} // closed when Close is called
145+
146+ // Diagnostic counters (atomic, no mutex needed).
147+ fillCount atomic.Uint64 // total Fill callbacks
148+ underrunCount atomic.Uint64 // Fill callbacks with no data available
149+ dropCount atomic.Uint64 // Write calls that dropped data (buffer full)
150+}
151+
152+// Write sends audio samples to the PipeWire output.
153+// Samples are provided as [channel][frame]float32 matching CEF's planar format.
154+// If the internal buffer is full the packet is dropped silently so the caller
155+// (CEF audio callback thread) is never blocked.
156+// Safe for concurrent use with Close.
157+func (a *playerStreamAdapter) Write(samples [][]float32) error {
158+ // Fast path: already closed.
159+ select {
160+ case <-a.closed:
161+ return ErrStreamClosed
162+ default:
163+ }
164+ // Non-blocking send: drop packet when buffer is full.
165+ select {
166+ case a.buf <- samples:
167+ return nil
168+ case <-a.closed:
169+ return ErrStreamClosed
170+ default:
171+ // Buffer full — drop to avoid blocking the CEF audio thread.
172+ n := a.dropCount.Add(1)
173+ if n == 1 || n&(n-1) == 0 { // first drop, then powers of two
174+ if a.ctx != nil {
175+ logging.FromContext(a.ctx).Warn().
176+ Uint64("total_drops", n).
177+ Msg("pipewire: dropped audio packet (buffer full)")
178+ }
179+ }
180+ return nil
181+ }
182+}
183+
184+// fill is the PlayerCallbacks.Fill function called by purego-pipewire
185+// when it needs audio data. It pulls from the internal buffer channel.
186+func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) {
187+ select {
188+ case samples := <-a.buf:
189+ fillNum := a.fillCount.Add(1)
190+ if fillNum == 1 && a.ctx != nil {
191+ logging.FromContext(a.ctx).Info().
192+ Msg("pipewire: first Fill callback with data")
193+ }
194+ // Copy available samples into the PCM buffer
195+ copied := copyToPCM(pcm, samples)
196+ return copied, nil
197+ default:
198+ // No data available — return 0 frames, let underrun policy handle it
199+ underruns := a.underrunCount.Add(1)
200+ if underruns == 1 && a.ctx != nil {
201+ logging.FromContext(a.ctx).Debug().
202+ Msg("pipewire: first Fill underrun (no data available)")
203+ }
204+ return 0, nil
205+ }
206+}
207+
208+// copyToPCM copies planar samples into the PCMBuffer.
209+// Returns the number of frames actually copied.
210+func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int {
211+ if pcm == nil || len(samples) == 0 {
212+ return 0
213+ }
214+
215+ frames := pcm.Frames
216+ channels := len(samples)
217+ if channels > pcm.Channels {
218+ channels = pcm.Channels
219+ }
220+ if channels > len(pcm.Samples) {
221+ channels = len(pcm.Samples)
222+ }
223+ if channels == 0 {
224+ return 0
225+ }
226+
227+ minFrames := frames
228+
229+ for ch := 0; ch < channels && ch < len(samples); ch++ {
230+ dst := pcm.Samples[ch]
231+ src := samples[ch]
232+ if len(dst) == 0 || len(src) == 0 {
233+ minFrames = 0
234+ continue
235+ }
236+ srcFrames := len(src)
237+ n := frames
238+ if srcFrames < n {
239+ n = srcFrames
240+ }
241+ if len(dst) < n {
242+ n = len(dst)
243+ }
244+ if n < minFrames {
245+ minFrames = n
246+ }
247+ copy(dst[:n], src[:n])
248+ }
249+
250+ return minFrames
251+}
252+
253+// Close stops the player and releases resources.
254+// Safe to call multiple times.
255+func (a *playerStreamAdapter) Close() error {
256+ var err error
257+ a.closeOnce.Do(func() {
258+ if a.ctx != nil {
259+ logging.FromContext(a.ctx).Info().
260+ Uint64("fills", a.fillCount.Load()).
261+ Uint64("underruns", a.underrunCount.Load()).
262+ Uint64("drops", a.dropCount.Load()).
263+ Msg("pipewire: closing player")
264+ }
265+
266+ // Signal writers to stop
267+ close(a.closed)
268+
269+ if a.player != nil {
270+ // Stop playback then close the player
271+ if stopErr := a.player.Stop(); stopErr != nil {
272+ err = stopErr
273+ }
274+ if closeErr := a.player.Close(); closeErr != nil && err == nil {
275+ err = closeErr
276+ }
277+ }
278+ })
279+ return err
280+}
A internal/infrastructure/audio/pipewire/output_test.go +548 -0
@@ -0,0 +1,548 @@
1+// Package pipewire provides a PipeWire-based audio output implementation.
2+// This wraps purego-pipewire's high-level Player API.
3+package pipewire
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ pwpipewire "github.com/bnema/purego-pipewire/pipewire"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// --- Test doubles ---
17+
18+// fakePlayer is a test double for purego-pipewire Player.
19+type fakePlayer struct {
20+ started bool
21+ paused bool
22+ stopped bool
23+ closedFlag bool
24+ state pwpipewire.PlayerState
25+
26+ startErr error
27+ stopErr error
28+ closeErr error
29+}
30+
31+func (p *fakePlayer) Start() error {
32+ if p.startErr != nil {
33+ return p.startErr
34+ }
35+ p.started = true
36+ p.state = pwpipewire.PlayerStatePlaying
37+ return nil
38+}
39+
40+func (p *fakePlayer) Pause() error {
41+ p.paused = true
42+ p.state = pwpipewire.PlayerStatePaused
43+ return nil
44+}
45+
46+func (p *fakePlayer) Stop() error {
47+ if p.stopErr != nil {
48+ return p.stopErr
49+ }
50+ p.stopped = true
51+ p.state = pwpipewire.PlayerStateStopped
52+ return nil
53+}
54+
55+func (p *fakePlayer) Close() error {
56+ if p.closeErr != nil {
57+ return p.closeErr
58+ }
59+ p.closedFlag = true
60+ p.state = pwpipewire.PlayerStateClosed
61+ return nil
62+}
63+
64+func (p *fakePlayer) State() pwpipewire.PlayerState {
65+ return p.state
66+}
67+
68+// --- Factory tests ---
69+
70+func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) {
71+ var capturedConfig pwpipewire.PlayerConfig
72+ player := &fakePlayer{}
73+
74+ factory := &Factory{
75+ createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
76+ capturedConfig = config
77+ return player, nil
78+ },
79+ }
80+
81+ format := port.AudioStreamFormat{
82+ SampleRate: 48000,
83+ ChannelCount: 2,
84+ FramesPerBuffer: 512,
85+ }
86+
87+ stream, err := factory.NewStream(context.Background(), format)
88+ require.NoError(t, err)
89+ require.NotNil(t, stream)
90+
91+ // Verify config mapping
92+ assert.Equal(t, 48000, capturedConfig.SampleRate)
93+ assert.Equal(t, 2, capturedConfig.Channels)
94+ assert.Equal(t, 512, capturedConfig.FramesPerBuffer)
95+ assert.Equal(t, pwpipewire.SampleFormatF32, capturedConfig.SampleFormat)
96+ assert.Equal(t, pwpipewire.UnderrunFillSilence, capturedConfig.UnderrunPolicy)
97+
98+ // Verify player was started
99+ assert.True(t, player.started)
100+
101+ // Clean up
102+ require.NoError(t, stream.Close())
103+}
104+
105+func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) {
106+ factory := &Factory{
107+ createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
108+ t.Fatal("createPlayer should not be called for invalid format")
109+ return nil, nil
110+ },
111+ }
112+
113+ tests := []struct {
114+ name string
115+ format port.AudioStreamFormat
116+ }{
117+ {
118+ name: "zero sample rate",
119+ format: port.AudioStreamFormat{SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512},
120+ },
121+ {
122+ name: "zero channels",
123+ format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512},
124+ },
125+ {
126+ name: "negative sample rate",
127+ format: port.AudioStreamFormat{SampleRate: -48000, ChannelCount: 2, FramesPerBuffer: 512},
128+ },
129+ {
130+ name: "negative channels",
131+ format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: -2, FramesPerBuffer: 512},
132+ },
133+ {
134+ name: "excessive sample rate",
135+ format: port.AudioStreamFormat{SampleRate: 1000000, ChannelCount: 2, FramesPerBuffer: 512},
136+ },
137+ {
138+ name: "excessive channels",
139+ format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 1025, FramesPerBuffer: 512},
140+ },
141+ {
142+ name: "zero frames per buffer",
143+ format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0},
144+ },
145+ {
146+ name: "excessive frames per buffer",
147+ format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 10000},
148+ },
149+ }
150+
151+ for _, tt := range tests {
152+ t.Run(tt.name, func(t *testing.T) {
153+ stream, err := factory.NewStream(context.Background(), tt.format)
154+ require.Error(t, err)
155+ assert.Nil(t, stream)
156+ assert.ErrorIs(t, err, ErrInvalidFormat)
157+ })
158+ }
159+}
160+
161+func TestFactory_NewStream_PlayerCreationFails_ReturnsError(t *testing.T) {
162+ expectedErr := errors.New("pipewire not available")
163+ factory := &Factory{
164+ createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
165+ return nil, expectedErr
166+ },
167+ }
168+
169+ format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
170+ stream, err := factory.NewStream(context.Background(), format)
171+
172+ require.Error(t, err)
173+ assert.Nil(t, stream)
174+ assert.ErrorIs(t, err, expectedErr)
175+}
176+
177+func TestFactory_NewStream_PlayerStartFails_ClosesAndReturnsError(t *testing.T) {
178+ player := &fakePlayer{startErr: errors.New("start failed")}
179+ factory := &Factory{
180+ createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
181+ return player, nil
182+ },
183+ }
184+
185+ format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
186+ stream, err := factory.NewStream(context.Background(), format)
187+
188+ require.Error(t, err)
189+ assert.Nil(t, stream)
190+ assert.True(t, player.closedFlag, "player should be closed after start failure")
191+}
192+
193+func TestFactory_NewStream_ContextCancelled_ReturnsError(t *testing.T) {
194+ factory := &Factory{
195+ createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
196+ t.Fatal("createPlayer should not be called when context is canceled")
197+ return nil, nil
198+ },
199+ }
200+
201+ ctx, cancel := context.WithCancel(context.Background())
202+ cancel()
203+
204+ format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
205+ stream, err := factory.NewStream(ctx, format)
206+
207+ require.Error(t, err)
208+ assert.Nil(t, stream)
209+}
210+
211+func TestFactory_NewStream_MultipleFormats_Accepted(t *testing.T) {
212+ formats := []port.AudioStreamFormat{
213+ {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512},
214+ {SampleRate: 44100, ChannelCount: 2, FramesPerBuffer: 512},
215+ {SampleRate: 48000, ChannelCount: 1, FramesPerBuffer: 256},
216+ {SampleRate: 96000, ChannelCount: 2, FramesPerBuffer: 1024},
217+ {SampleRate: 48000, ChannelCount: 6, FramesPerBuffer: 512},
218+ }
219+
220+ for _, fmt := range formats {
221+ t.Run("", func(t *testing.T) {
222+ factory := &Factory{
223+ createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
224+ assert.Equal(t, fmt.SampleRate, config.SampleRate)
225+ assert.Equal(t, fmt.ChannelCount, config.Channels)
226+ assert.Equal(t, fmt.FramesPerBuffer, config.FramesPerBuffer)
227+ return &fakePlayer{}, nil
228+ },
229+ }
230+
231+ stream, err := factory.NewStream(context.Background(), fmt)
232+ require.NoError(t, err)
233+ assert.NotNil(t, stream)
234+
235+ _ = stream.Close()
236+ })
237+ }
238+}
239+
240+func TestFactory_ImplementsPortInterface(_ *testing.T) {
241+ var _ port.AudioOutputFactory = (*Factory)(nil)
242+}
243+
244+// --- Stream adapter tests ---
245+
246+func TestPlayerStreamAdapter_Write_SendsToBuffer(t *testing.T) {
247+ player := &fakePlayer{}
248+ adapter := &playerStreamAdapter{
249+ player: player,
250+ buf: make(chan [][]float32, 4),
251+ closed: make(chan struct{}),
252+ }
253+
254+ samples := [][]float32{
255+ {0.1, 0.2},
256+ {0.3, 0.4},
257+ }
258+
259+ err := adapter.Write(samples)
260+ require.NoError(t, err)
261+
262+ // Verify data is in the channel
263+ received := <-adapter.buf
264+ assert.Equal(t, samples, received)
265+}
266+
267+func TestPlayerStreamAdapter_Write_AfterClose_ReturnsError(t *testing.T) {
268+ player := &fakePlayer{}
269+ adapter := &playerStreamAdapter{
270+ player: player,
271+ buf: make(chan [][]float32, 4),
272+ closed: make(chan struct{}),
273+ }
274+
275+ require.NoError(t, adapter.Close())
276+
277+ err := adapter.Write([][]float32{{0.1}})
278+ assert.ErrorIs(t, err, ErrStreamClosed)
279+}
280+
281+func TestPlayerStreamAdapter_Close_StopsAndClosesPlayer(t *testing.T) {
282+ player := &fakePlayer{}
283+ adapter := &playerStreamAdapter{
284+ player: player,
285+ buf: make(chan [][]float32, 4),
286+ closed: make(chan struct{}),
287+ }
288+
289+ err := adapter.Close()
290+ require.NoError(t, err)
291+
292+ assert.True(t, player.stopped)
293+ assert.True(t, player.closedFlag)
294+}
295+
296+func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) {
297+ player := &fakePlayer{}
298+ adapter := &playerStreamAdapter{
299+ player: player,
300+ buf: make(chan [][]float32, 4),
301+ closed: make(chan struct{}),
302+ }
303+
304+ require.NoError(t, adapter.Close())
305+ require.NoError(t, adapter.Close())
306+}
307+
308+func TestPlayerStreamAdapter_Close_PropagatesStopError(t *testing.T) {
309+ player := &fakePlayer{stopErr: errors.New("stop error")}
310+ adapter := &playerStreamAdapter{
311+ player: player,
312+ buf: make(chan [][]float32, 4),
313+ closed: make(chan struct{}),
314+ }
315+
316+ err := adapter.Close()
317+ require.Error(t, err)
318+ assert.Contains(t, err.Error(), "stop error")
319+}
320+
321+func TestPlayerStreamAdapter_Close_PropagatesCloseError(t *testing.T) {
322+ player := &fakePlayer{closeErr: errors.New("close error")}
323+ adapter := &playerStreamAdapter{
324+ player: player,
325+ buf: make(chan [][]float32, 4),
326+ closed: make(chan struct{}),
327+ }
328+
329+ err := adapter.Close()
330+ require.Error(t, err)
331+ assert.Contains(t, err.Error(), "close error")
332+}
333+
334+// --- Fill callback tests ---
335+
336+func TestFill_PullsFromBuffer(t *testing.T) {
337+ adapter := &playerStreamAdapter{
338+ buf: make(chan [][]float32, 4),
339+ }
340+
341+ // Push samples
342+ samples := [][]float32{
343+ {0.1, 0.2, 0.3},
344+ {0.4, 0.5, 0.6},
345+ }
346+ adapter.buf <- samples
347+
348+ // Simulate Fill call
349+ pcm := &pwpipewire.PCMBuffer{
350+ Frames: 3,
351+ Channels: 2,
352+ Stride: 1,
353+ Samples: [][]float32{
354+ make([]float32, 3),
355+ make([]float32, 3),
356+ },
357+ }
358+
359+ frames, err := adapter.fill(pcm)
360+ require.NoError(t, err)
361+ assert.Equal(t, 3, frames)
362+ assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)
363+ assert.InEpsilon(t, 0.4, pcm.Samples[1][0], 0.0001)
364+}
365+
366+func TestFill_ReturnsZeroWhenEmpty(t *testing.T) {
367+ adapter := &playerStreamAdapter{
368+ buf: make(chan [][]float32, 4),
369+ }
370+
371+ pcm := &pwpipewire.PCMBuffer{
372+ Frames: 256,
373+ Channels: 2,
374+ Stride: 1,
375+ Samples: [][]float32{
376+ make([]float32, 256),
377+ make([]float32, 256),
378+ },
379+ }
380+
381+ frames, err := adapter.fill(pcm)
382+ require.NoError(t, err)
383+ assert.Equal(t, 0, frames)
384+}
385+
386+func TestCopyToPCM_HandlesChannelMismatch(t *testing.T) {
387+ // More source channels than PCM channels — truncates
388+ pcm := &pwpipewire.PCMBuffer{
389+ Frames: 2,
390+ Channels: 1,
391+ Samples: [][]float32{make([]float32, 2)},
392+ }
393+ samples := [][]float32{
394+ {1.0, 2.0},
395+ {3.0, 4.0}, // should be ignored
396+ }
397+ frames := copyToPCM(pcm, samples)
398+ assert.Equal(t, 2, frames)
399+ assert.InEpsilon(t, 1.0, pcm.Samples[0][0], 0.0001)
400+}
401+
402+func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) {
403+ // Fewer source frames than PCM expects
404+ pcm := &pwpipewire.PCMBuffer{
405+ Frames: 4,
406+ Channels: 1,
407+ Samples: [][]float32{make([]float32, 4)},
408+ }
409+ samples := [][]float32{
410+ {1.0, 2.0}, // only 2 frames
411+ }
412+ frames := copyToPCM(pcm, samples)
413+ assert.Equal(t, 2, frames) // reports partial fill
414+}
415+
416+func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) {
417+ assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}}))
418+}
419+
420+func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) {
421+ pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}}
422+ assert.Equal(t, 0, copyToPCM(pcm, nil))
423+ assert.Equal(t, 0, copyToPCM(pcm, [][]float32{}))
424+}
425+
426+// --- Non-blocking Write tests ---
427+
428+func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) {
429+ adapter := &playerStreamAdapter{
430+ player: &fakePlayer{},
431+ buf: make(chan [][]float32, 4),
432+ closed: make(chan struct{}),
433+ }
434+
435+ // Fill the buffer to capacity
436+ for i := 0; i < 4; i++ {
437+ require.NoError(t, adapter.Write([][]float32{{float32(i)}}))
438+ }
439+
440+ // The 5th write must NOT block — it should drop the packet and return nil.
441+ err := adapter.Write([][]float32{{99.0}})
442+ require.NoError(t, err, "Write must not return an error when dropping due to full buffer")
443+
444+ // Verify the drop was counted
445+ assert.Equal(t, uint64(1), adapter.dropCount.Load(), "one packet should have been dropped")
446+
447+ // Verify buffer still contains the original 4 packets (not the dropped one)
448+ assert.Len(t, adapter.buf, 4)
449+}
450+
451+func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) {
452+ adapter := &playerStreamAdapter{
453+ player: &fakePlayer{},
454+ buf: make(chan [][]float32, 1), // tiny buffer to force drops
455+ closed: make(chan struct{}),
456+ }
457+
458+ // Fill buffer
459+ require.NoError(t, adapter.Write([][]float32{{0.0}}))
460+
461+ // Drop 10 packets
462+ for i := 0; i < 10; i++ {
463+ _ = adapter.Write([][]float32{{float32(i)}})
464+ }
465+
466+ // Verify all drops were counted
467+ assert.Equal(t, uint64(10), adapter.dropCount.Load())
468+}
469+
470+// --- Concurrent Write / Close tests ---
471+
472+func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) {
473+ player := &fakePlayer{}
474+ adapter := &playerStreamAdapter{
475+ player: player,
476+ buf: make(chan [][]float32, 4),
477+ closed: make(chan struct{}),
478+ }
479+
480+ done := make(chan struct{})
481+ go func() {
482+ defer close(done)
483+ for i := 0; i < 100; i++ {
484+ err := adapter.Write([][]float32{{float32(i)}})
485+ if err != nil {
486+ assert.ErrorIs(t, err, ErrStreamClosed)
487+ return
488+ }
489+ }
490+ }()
491+
492+ // Let a few writes land, then close.
493+ adapter.Close()
494+ <-done
495+}
496+
497+// --- NewFactory tests ---
498+
499+func TestNewFactory_Succeeds(t *testing.T) {
500+ factory, err := NewFactory()
501+ require.NoError(t, err)
502+ require.NotNil(t, factory)
503+ assert.NotNil(t, factory.createPlayer)
504+}
505+
506+// --- Integration-style test: full write-through ---
507+
508+func TestStream_WriteFlowsThroughToFill(t *testing.T) {
509+ var capturedCallbacks pwpipewire.PlayerCallbacks
510+ player := &fakePlayer{}
511+
512+ factory := &Factory{
513+ createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
514+ capturedCallbacks = callbacks
515+ return player, nil
516+ },
517+ }
518+
519+ format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4}
520+ stream, err := factory.NewStream(context.Background(), format)
521+ require.NoError(t, err)
522+
523+ // Write samples through the stream
524+ samples := [][]float32{
525+ {0.1, 0.2, 0.3, 0.4},
526+ {0.5, 0.6, 0.7, 0.8},
527+ }
528+ require.NoError(t, stream.Write(samples))
529+
530+ // Simulate the Fill callback being called by the player
531+ pcm := &pwpipewire.PCMBuffer{
532+ Frames: 4,
533+ Channels: 2,
534+ Stride: 1,
535+ Samples: [][]float32{
536+ make([]float32, 4),
537+ make([]float32, 4),
538+ },
539+ }
540+
541+ frames, err := capturedCallbacks.Fill(pcm)
542+ require.NoError(t, err)
543+ assert.Equal(t, 4, frames)
544+ assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)
545+ assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001)
546+
547+ require.NoError(t, stream.Close())
548+}
A internal/infrastructure/cef/app.go +134 -0
@@ -0,0 +1,134 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+const dumbSchemeName = "dumb"
10+
11+func dumbSchemeOptions() int32 {
12+ return purecef.SchemeOptionsSchemeOptionStandard |
13+ purecef.SchemeOptionsSchemeOptionSecure |
14+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
15+ purecef.SchemeOptionsSchemeOptionCspBypassing |
16+ purecef.SchemeOptionsSchemeOptionFetchEnabled
17+}
18+
19+func registerDumbScheme(registrar purecef.SchemeRegistrar) {
20+ if registrar == nil {
21+ return
22+ }
23+ registrar.AddCustomScheme(dumbSchemeName, dumbSchemeOptions())
24+}
25+
26+func configureCommandLine(commandLine purecef.CommandLine) {
27+ if commandLine == nil {
28+ return
29+ }
30+
31+ // Enable Chromium's built-in smooth scrolling animation — without this,
32+ // mouse wheel scroll jumps in discrete steps with no momentum/easing.
33+ commandLine.AppendSwitch("enable-smooth-scrolling")
34+
35+ // Allow video autoplay without requiring user gesture — sites like
36+ // Reddit autoplay muted videos in the feed; without this policy
37+ // Chromium blocks them, showing an infinite spinner.
38+ commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
39+}
40+
41+// dumberApp implements purecef.App to provide custom scheme registration,
42+// command-line configuration, and browser-process lifecycle callbacks.
43+type dumberApp struct {
44+ engine *Engine
45+ bph purecef.BrowserProcessHandler
46+}
47+
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.
51+func newDumberApp(engine *Engine) purecef.App {
52+ app := &dumberApp{engine: engine}
53+ app.bph = &dumberBPH{engine: engine}
54+ return app
55+}
56+
57+// NewSubprocessApp returns a lightweight App for helper processes so CEF sees
58+// the same custom scheme registration in renderer/GPU/utility processes.
59+func NewSubprocessApp() purecef.App {
60+ return purecef.NewApp(&subprocessApp{})
61+}
62+
63+// maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
64+const maxCmdLineLogLen = 200
65+
66+func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
67+ log := logging.FromContext(a.engine.ctx)
68+ if commandLine != nil {
69+ configureCommandLine(commandLine)
70+
71+ cmdline := commandLine.GetCommandLineString()
72+ if len(cmdline) > maxCmdLineLogLen {
73+ runes := []rune(cmdline)
74+ if len(runes) > maxCmdLineLogLen {
75+ cmdline = string(runes[:maxCmdLineLogLen]) + "…"
76+ }
77+ }
78+ log.Debug().
79+ Str("process_type", processType).
80+ Str("command_line", cmdline).
81+ Msg("cef: OnBeforeCommandLineProcessing")
82+ }
83+}
84+func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
85+ registerDumbScheme(registrar)
86+ logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")
87+}
88+func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
89+func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
90+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
91+
92+type subprocessApp struct{}
93+
94+func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
95+ configureCommandLine(commandLine)
96+}
97+func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
98+ registerDumbScheme(registrar)
99+}
100+func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
101+func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
102+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
103+
104+// dumberBPH implements purecef.BrowserProcessHandler for context initialization,
105+// child process launch tracking, and diagnostic logging.
106+type dumberBPH struct {
107+ engine *Engine
108+}
109+
110+func (h *dumberBPH) OnRegisterCustomPreferences(_ purecef.PreferencesType, _ purecef.PreferenceRegistrar) {
111+}
112+func (h *dumberBPH) OnContextInitialized() {
113+ h.engine.recordContextInitialized()
114+}
115+func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) {
116+ processType := ""
117+ commandLineString := ""
118+ useAngle := ""
119+ ozonePlatform := ""
120+ if commandLine != nil {
121+ processType = commandLine.GetSwitchValue("type")
122+ commandLineString = commandLine.GetCommandLineString()
123+ useAngle = commandLine.GetSwitchValue("use-angle")
124+ ozonePlatform = commandLine.GetSwitchValue("ozone-platform")
125+ }
126+ h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
127+}
128+func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 }
129+func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
130+func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
131+
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) {}
A internal/infrastructure/cef/app_test.go +43 -0
@@ -0,0 +1,43 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+type schemeRegistrarStub struct {
10+ scheme string
11+ options int32
12+ calls int
13+}
14+
15+func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) int32 {
16+ s.calls++
17+ s.scheme = schemeName
18+ s.options = options
19+ return 1
20+}
21+
22+func TestRegisterDumbScheme(t *testing.T) {
23+ t.Parallel()
24+
25+ stub := &schemeRegistrarStub{}
26+ registerDumbScheme(stub)
27+
28+ wantOptions := purecef.SchemeOptionsSchemeOptionStandard |
29+ purecef.SchemeOptionsSchemeOptionSecure |
30+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
31+ purecef.SchemeOptionsSchemeOptionCspBypassing |
32+ purecef.SchemeOptionsSchemeOptionFetchEnabled
33+
34+ if stub.calls != 1 {
35+ t.Fatalf("AddCustomScheme call count = %d, want 1", stub.calls)
36+ }
37+ if stub.scheme != dumbSchemeName {
38+ t.Fatalf("scheme = %q, want %q", stub.scheme, dumbSchemeName)
39+ }
40+ if stub.options != wantOptions {
41+ t.Fatalf("options = %d, want %d", stub.options, wantOptions)
42+ }
43+}
A internal/infrastructure/cef/content_injector.go +577 -0
@@ -0,0 +1,577 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/infrastructure/webutil"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// Compile-time interface check.
17+var _ port.ContentInjector = (*contentInjector)(nil)
18+
19+// scrollbarCSS styles the scrollbar with auto-hide behavior: invisible by
20+// default, fades in on scroll, widens on hover, fades out after 1s idle.
21+// Uses --primary theme color for the thumb.
22+const scrollbarCSS = `
23+::-webkit-scrollbar {
24+ width: 6px;
25+ height: 6px;
26+}
27+::-webkit-scrollbar-track {
28+ background: transparent;
29+}
30+::-webkit-scrollbar-thumb {
31+ background: transparent;
32+ border-radius: 3px;
33+}
34+.dumber-scrolling ::-webkit-scrollbar-thumb {
35+ background: var(--primary, rgba(128, 128, 128, 0.4));
36+}
37+.dumber-scrolling ::-webkit-scrollbar:hover {
38+ width: 10px;
39+ height: 10px;
40+}
41+.dumber-scrolling ::-webkit-scrollbar-track:hover {
42+ background: rgba(128, 128, 128, 0.1);
43+}
44+.dumber-scrolling ::-webkit-scrollbar-thumb:hover {
45+ background: var(--primary, rgba(128, 128, 128, 0.6));
46+}
47+::-webkit-scrollbar-corner {
48+ background: transparent;
49+}
50+`
51+
52+// scrollbarAutoHideJS adds/removes the .dumber-scrolling class on <html>
53+// on scroll activity, with a 1s fade-out timeout after scrolling stops.
54+const scrollbarAutoHideJS = `(function(){
55+ var t, el = document.documentElement;
56+ function show() {
57+ el.classList.add('dumber-scrolling');
58+ clearTimeout(t);
59+ t = setTimeout(function(){ el.classList.remove('dumber-scrolling'); }, 1000);
60+ }
61+ window.addEventListener('scroll', show, {passive:true,capture:true});
62+ window.addEventListener('wheel', show, {passive:true});
63+ el.addEventListener('mouseenter', function(){
64+ if(el.scrollHeight > el.clientHeight) show();
65+ });
66+})();`
67+
68+// videoDiagnosticJS monitors all <video> elements on the page and logs their
69+// state changes to the console. This helps diagnose playback issues in CEF OSR.
70+// TODO: remove once video playback is stable.
71+const videoDiagnosticJS = `(function(){
72+ var tag = '[VIDEO-DIAG]';
73+ var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough',
74+ 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend'];
75+
76+ function monitor(v, label) {
77+ if (v._dumberDiag) return;
78+ v._dumberDiag = true;
79+ console.warn(tag, label, 'src:', (v.src||'').substring(0,80),
80+ 'currentSrc:', (v.currentSrc||'').substring(0,80),
81+ 'readyState:', v.readyState, 'networkState:', v.networkState,
82+ 'preload:', v.preload, 'autoplay:', v.autoplay);
83+ events.forEach(function(evt) {
84+ v.addEventListener(evt, function() {
85+ var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none';
86+ console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState,
87+ 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80));
88+ });
89+ });
90+ }
91+
92+ // Recursively scan a root (document or shadowRoot) for video elements.
93+ function scanRoot(root, label) {
94+ root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); });
95+ // Pierce shadow DOMs.
96+ root.querySelectorAll('*').forEach(function(el) {
97+ if (el.shadowRoot) {
98+ scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
99+ // Observe inside shadow root too.
100+ observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
101+ }
102+ });
103+ }
104+
105+ function observeRoot(root, label) {
106+ new MutationObserver(function(muts) {
107+ muts.forEach(function(m) {
108+ m.addedNodes.forEach(function(n) {
109+ if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn');
110+ if (n.querySelectorAll) {
111+ n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); });
112+ }
113+ // New element might have shadow root.
114+ if (n.shadowRoot) {
115+ scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
116+ observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
117+ }
118+ });
119+ });
120+ }).observe(root, {childList:true, subtree:true});
121+ }
122+
123+ // Patch attachShadow to automatically observe new shadow roots.
124+ // Use a marker to avoid double-patching if redditDirectVideoJS also patches.
125+ var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;
126+ if (!Element.prototype.__dumberPatched) {
127+ Element.prototype.__dumberOrigAttachShadow = origAttach;
128+ Element.prototype.__dumberPatched = true;
129+ }
130+ Element.prototype.attachShadow = function(opts) {
131+ var sr = origAttach.call(this, opts);
132+ console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
133+ observeRoot(sr, 'shadow>'+this.tagName.toLowerCase());
134+ return sr;
135+ };
136+
137+ // Also intercept MediaSource to trace usage.
138+ if (window.MediaSource) {
139+ var origAddSB = MediaSource.prototype.addSourceBuffer;
140+ MediaSource.prototype.addSourceBuffer = function(mime) {
141+ console.warn(tag, 'MediaSource.addSourceBuffer:', mime,
142+ 'supported:', MediaSource.isTypeSupported(mime));
143+ try { return origAddSB.call(this, mime); }
144+ catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; }
145+ };
146+ var origURL = URL.createObjectURL;
147+ URL.createObjectURL = function(obj) {
148+ var url = origURL.call(this, obj);
149+ if (obj instanceof MediaSource) {
150+ console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80));
151+ }
152+ return url;
153+ };
154+ }
155+
156+ // Initial scan.
157+ scanRoot(document, 'doc');
158+
159+ // Observe document for new elements.
160+ observeRoot(document.documentElement, 'doc');
161+
162+ console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource,
163+ 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a',
164+ 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
165+})();`
166+
167+// redditDirectVideoJS replaces Reddit's blob/MSE playback path with a direct
168+// HLS source when MP4 MSE buffers are unsupported. This lets the browser issue
169+// a normal media request that our transcoding handler can answer with WebM.
170+const redditDirectVideoJS = `(function(){
171+ var tag = '[REDDIT-VIDEO-PATCH]';
172+ if (!/(\.|^)reddit\.com$/i.test(location.hostname)) return;
173+ if (!window.MediaSource) return;
174+ if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') &&
175+ MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) {
176+ return;
177+ }
178+
179+ console.warn(tag, 'active for unsupported Reddit MSE playback');
180+
181+ function ensureStyle() {
182+ if (document.getElementById('dumber-reddit-direct-video-style')) return;
183+ var style = document.createElement('style');
184+ style.id = 'dumber-reddit-direct-video-style';
185+ style.textContent =
186+ 'shreddit-player[data-dumber-direct-video="1"] { position: relative !important; }' +
187+ 'shreddit-player[data-dumber-direct-video="1"] > .dumber-direct-video {' +
188+ ' position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important;' +
189+ ' object-fit: contain !important; background: #000 !important; z-index: 2147483647 !important;' +
190+ ' display: block !important; }';
191+ document.head.appendChild(style);
192+ }
193+
194+ function candidateRoots(video) {
195+ var roots = [];
196+ var seen = [];
197+ function add(root) {
198+ if (!root) return;
199+ if (seen.indexOf(root) !== -1) return;
200+ seen.push(root);
201+ roots.push(root);
202+ }
203+
204+ var current = video;
205+ while (current) {
206+ add(current);
207+ if (current.shadowRoot) add(current.shadowRoot);
208+ current = current.parentElement;
209+ }
210+ add(document);
211+ return roots;
212+ }
213+
214+ function findHlsSource(video) {
215+ var selectors = [
216+ 'source[type="application/vnd.apple.mpegURL"][src]',
217+ 'source[type="application/x-mpegURL"][src]',
218+ 'source[src*="HLSPlaylist.m3u8"]',
219+ '[src*="HLSPlaylist.m3u8"]'
220+ ];
221+
222+ var roots = candidateRoots(video);
223+ for (var i = 0; i < roots.length; i++) {
224+ var root = roots[i];
225+ if (!root.querySelector) continue;
226+ for (var j = 0; j < selectors.length; j++) {
227+ var el = root.querySelector(selectors[j]);
228+ if (el && el.src) return el.src;
229+ }
230+ if (root.querySelectorAll) {
231+ var all = root.querySelectorAll('*');
232+ for (var k = 0; k < all.length; k++) {
233+ if (all[k].shadowRoot) {
234+ roots.push(all[k].shadowRoot);
235+ }
236+ }
237+ }
238+ }
239+ return '';
240+ }
241+
242+ function buildPlaybackURL(hlsURL) {
243+ return location.origin + '/__dumber__/transcode.webm?src=' + encodeURIComponent(hlsURL) +
244+ '&referer=' + encodeURIComponent(location.href) +
245+ '&origin=' + encodeURIComponent(location.origin);
246+ }
247+
248+ function getPlayerRoot(video) {
249+ return video.closest('shreddit-player') || video.parentElement;
250+ }
251+
252+ function monitorReplacement(video) {
253+ if (!video || video._dumberReplacementDiag) return;
254+ video._dumberReplacementDiag = true;
255+ [
256+ 'loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'playing',
257+ 'waiting', 'stalled', 'suspend', 'abort', 'emptied', 'error',
258+ ].forEach(function(evt) {
259+ video.addEventListener(evt, function() {
260+ var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none';
261+ console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState,
262+ 'paused:', video.paused, 'error:', err, 'src:',
263+ (video.currentSrc || video.src || '').substring(0, 120));
264+ });
265+ });
266+ }
267+
268+ function ensureReplacement(video, playbackURL) {
269+ var root = getPlayerRoot(video);
270+ if (!root) return null;
271+ ensureStyle();
272+
273+ var replacement = root.querySelector(':scope > .dumber-direct-video');
274+ if (!replacement) {
275+ replacement = document.createElement('video');
276+ replacement.className = 'dumber-direct-video';
277+ replacement.controls = true;
278+ replacement.playsInline = true;
279+ replacement.preload = 'auto';
280+ replacement.autoplay = true;
281+ replacement.muted = true;
282+ replacement.loop = video.loop;
283+ root.setAttribute('data-dumber-direct-video', '1');
284+ root.appendChild(replacement);
285+ }
286+
287+ replacement.poster = video.poster || replacement.poster || '';
288+ monitorReplacement(replacement);
289+ if (replacement.src !== playbackURL) {
290+ replacement.src = playbackURL;
291+ replacement.load();
292+ }
293+ return replacement;
294+ }
295+
296+ function shouldForce(video) {
297+ var current = video.currentSrc || video.src || '';
298+ return current.indexOf('blob:') === 0;
299+ }
300+
301+ function forceDirectPlayback(video) {
302+ if (!video || !shouldForce(video)) return;
303+
304+ var hlsURL = findHlsSource(video);
305+ if (!hlsURL) {
306+ console.warn(tag, 'blob video found without HLS source');
307+ return;
308+ }
309+ var playbackURL = buildPlaybackURL(hlsURL);
310+ if (video._dumberPlaybackURL === playbackURL) return;
311+ video._dumberPlaybackURL = playbackURL;
312+
313+ console.warn(tag, 'forcing replacement video source:', playbackURL.substring(0, 120), 'from:', hlsURL.substring(0, 120));
314+
315+ var replacement = ensureReplacement(video, playbackURL);
316+ if (!replacement) return;
317+
318+ var root = getPlayerRoot(video);
319+ if (root && root.querySelectorAll) {
320+ root.querySelectorAll('video').forEach(function(candidate) {
321+ if (candidate === replacement) return;
322+ try { candidate.pause(); } catch (e) {}
323+ try { candidate.removeAttribute('src'); } catch (e) {}
324+ try { candidate.srcObject = null; } catch (e) {}
325+ try { candidate.load(); } catch (e) {}
326+ candidate.style.display = 'none';
327+ });
328+ } else {
329+ try { video.pause(); } catch (e) {}
330+ try { video.removeAttribute('src'); } catch (e) {}
331+ try { video.srcObject = null; } catch (e) {}
332+ try { video.load(); } catch (e) {}
333+ video.style.display = 'none';
334+ }
335+
336+ var playPromise = replacement.play();
337+ if (playPromise && typeof playPromise.catch === 'function') {
338+ playPromise.catch(function(err) {
339+ console.warn(tag, 'replacement video.play() rejected:', err && err.message ? err.message : err);
340+ });
341+ }
342+ }
343+
344+ function patchTree(root) {
345+ if (!root || !root.querySelectorAll) return;
346+ root.querySelectorAll('video').forEach(forceDirectPlayback);
347+ root.querySelectorAll('*').forEach(function(el) {
348+ if (el.shadowRoot) patchTree(el.shadowRoot);
349+ });
350+ }
351+
352+ new MutationObserver(function(mutations) {
353+ mutations.forEach(function(mutation) {
354+ if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeName === 'VIDEO') {
355+ forceDirectPlayback(mutation.target);
356+ }
357+ mutation.addedNodes.forEach(function(node) {
358+ if (node.nodeName === 'VIDEO') forceDirectPlayback(node);
359+ patchTree(node);
360+ if (node.shadowRoot) patchTree(node.shadowRoot);
361+ });
362+ });
363+ }).observe(document.documentElement, {
364+ subtree: true,
365+ childList: true,
366+ attributes: true,
367+ attributeFilter: ['src']
368+ });
369+
370+ // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting.
371+ var prevAttach = Element.prototype.attachShadow;
372+ Element.prototype.attachShadow = function(opts) {
373+ var sr = prevAttach.call(this, opts);
374+ patchTree(sr);
375+ return sr;
376+ };
377+
378+ patchTree(document);
379+ var pollCount = 0;
380+ var maxPolls = 30;
381+ var pollID = setInterval(function() {
382+ patchTree(document);
383+ pollCount++;
384+ if (pollCount >= maxPolls) clearInterval(pollID);
385+ }, 1000);
386+})();`
387+
388+// clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text
389+// back to Go via the message bridge so it can be written to the GDK clipboard.
390+// Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but
391+// not the Wayland/X11 system clipboard.
392+const clipboardCopyBridgeJS = `(function(){
393+ if (window.__dumberClipboardBridge) return;
394+ window.__dumberClipboardBridge = true;
395+ function sendToClipboard(text) {
396+ if (!text) return;
397+ var body = JSON.stringify({text: text});
398+ var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
399+ if (!encoded) return;
400+ fetch('dumb:///api/clipboard-set', {
401+ method: 'POST',
402+ headers: {'X-Dumber-Body': encoded}
403+ }).catch(function(){});
404+ }
405+ document.addEventListener('copy', function() {
406+ var sel = window.getSelection();
407+ if (sel) sendToClipboard(sel.toString());
408+ });
409+ document.addEventListener('cut', function() {
410+ var sel = window.getSelection();
411+ if (sel) sendToClipboard(sel.toString());
412+ });
413+})();`
414+
415+// contentInjector implements port.ContentInjector for the CEF engine.
416+// It stores CSS strings and injects them into webviews via ExecuteJavaScript.
417+// Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
418+// fires on the CEF IO thread.
419+type contentInjector struct {
420+ mu sync.RWMutex
421+ themeCSS string
422+ findHighlightCSS string
423+ engine *Engine
424+ colorResolver port.ColorSchemeResolver
425+ videoDiagnosticsEnabled bool
426+}
427+
428+// setColorResolver updates the color scheme resolver used for dark mode detection.
429+func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) {
430+ ci.mu.Lock()
431+ defer ci.mu.Unlock()
432+ ci.colorResolver = resolver
433+}
434+
435+// newContentInjector creates a content injector wired to the given engine.
436+// Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set.
437+func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
438+ diagEnabled := envBoolEnabled("DUMBER_VIDEO_DIAG")
439+ return &contentInjector{
440+ engine: engine,
441+ colorResolver: resolver,
442+ videoDiagnosticsEnabled: diagEnabled,
443+ }
444+}
445+
446+// InjectThemeCSS stores the theme CSS and broadcasts it to all active webviews.
447+func (ci *contentInjector) InjectThemeCSS(ctx context.Context, css string) error {
448+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
449+
450+ ci.mu.Lock()
451+ ci.themeCSS = css
452+ ci.mu.Unlock()
453+
454+ log.Debug().Int("css_len", len(css)).Msg("theme CSS set, broadcasting to active webviews")
455+
456+ // Broadcast to all active webviews.
457+ ci.engine.activeWebViews.Range(func(_, value any) bool {
458+ if wv, ok := value.(*WebView); ok {
459+ ci.injectCSS(wv, "dumber-theme-vars", css)
460+ }
461+ return true
462+ })
463+ return nil
464+}
465+
466+// InjectFindHighlightCSS stores the find highlight CSS and broadcasts it.
467+func (ci *contentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
468+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
469+
470+ ci.mu.Lock()
471+ ci.findHighlightCSS = css
472+ ci.mu.Unlock()
473+
474+ log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set, broadcasting to active webviews")
475+
476+ ci.engine.activeWebViews.Range(func(_, value any) bool {
477+ if wv, ok := value.(*WebView); ok {
478+ ci.injectCSS(wv, "dumber-find-highlight", css)
479+ }
480+ return true
481+ })
482+ return nil
483+}
484+
485+// RefreshScripts re-injects all scripts into a specific webview.
486+func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
487+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
488+ if wv == nil {
489+ log.Debug().Msg("RefreshScripts: nil webview")
490+ return nil
491+ }
492+ cefWV, ok := wv.(*WebView)
493+ if !ok {
494+ log.Debug().Msg("RefreshScripts: webview is not *cef.WebView")
495+ return nil
496+ }
497+
498+ ci.onLoadEnd(cefWV)
499+ return nil
500+}
501+
502+// onLoadEnd is called from the load handler after a page finishes loading.
503+// It injects the appropriate scripts based on whether the page is internal.
504+func (ci *contentInjector) onLoadEnd(wv *WebView) {
505+ uri := wv.URI()
506+ isInternal := isConceptualInternalURL(uri) || isActualInternalURL(uri)
507+
508+ ci.mu.RLock()
509+ themeCSS := ci.themeCSS
510+ findCSS := ci.findHighlightCSS
511+ ci.mu.RUnlock()
512+
513+ // Internal pages get dark mode + message bridge + theme CSS.
514+ if isInternal {
515+ prefersDark := false
516+ if ci.colorResolver != nil {
517+ prefersDark = ci.colorResolver.Resolve().PrefersDark
518+ }
519+ ci.injectDarkModeScript(wv, prefersDark)
520+ ci.injectMessageBridgeShim(wv)
521+ if themeCSS != "" {
522+ ci.injectCSS(wv, "dumber-theme-vars", themeCSS)
523+ }
524+ }
525+
526+ // All pages get find highlight CSS if set.
527+ if findCSS != "" {
528+ ci.injectCSS(wv, "dumber-find-highlight", findCSS)
529+ }
530+
531+ // All pages get custom scrollbar styling with auto-hide.
532+ ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
533+ wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
534+
535+ // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard).
536+ wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS)
537+
538+ // Video playback diagnostic — logs video element state changes.
539+ // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
540+ if ci.videoDiagnosticsEnabled {
541+ wv.RunJavaScript(context.Background(), videoDiagnosticJS)
542+ }
543+
544+ if parsed, err := url.Parse(uri); err == nil {
545+ host := parsed.Hostname()
546+ if host == "reddit.com" || strings.HasSuffix(host, ".reddit.com") {
547+ wv.RunJavaScript(context.Background(), redditDirectVideoJS)
548+ }
549+ }
550+}
551+
552+// injectCSS injects a CSS string as a <style> element via JavaScript.
553+func (ci *contentInjector) injectCSS(wv *WebView, id, css string) {
554+ escapedID := webutil.EscapeForJSString(id)
555+ escaped := webutil.EscapeForJSString(css)
556+ script := fmt.Sprintf(`(function(){
557+ var el = document.getElementById('%s');
558+ if (!el) { el = document.createElement('style'); el.id = '%s'; document.head.appendChild(el); }
559+ el.textContent = '%s';
560+})();`, escapedID, escapedID, escaped)
561+
562+ wv.RunJavaScript(context.Background(), script)
563+}
564+
565+// injectDarkModeScript sets dark/light class on <html> and patches matchMedia
566+// for prefers-color-scheme queries on internal pages.
567+func (ci *contentInjector) injectDarkModeScript(wv *WebView, prefersDark bool) {
568+ wv.RunJavaScript(context.Background(), webutil.DarkModeScript(prefersDark, "__dumber_cef_prefers_dark"))
569+}
570+
571+// injectMessageBridgeShim injects the window.dumber.postMessage JS client shim
572+// so internal pages can communicate with Go handlers via fetch.
573+// The message body is base64-encoded into the X-Dumber-Body header to work
574+// around purego-cef's unexported PostData element wrapper.
575+func (ci *contentInjector) injectMessageBridgeShim(wv *WebView) {
576+ wv.RunJavaScript(context.Background(), MessageBridgeJS)
577+}
A internal/infrastructure/cef/context_menu.go +142 -0
@@ -0,0 +1,142 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+// menuEntry holds a snapshot of one CEF menu item.
10+type menuEntry struct {
11+ label string
12+ commandID int32
13+ isSep bool
14+}
15+
16+// ===========================================================================
17+// ContextMenuHandler (implemented on handlerSet)
18+// ===========================================================================
19+
20+func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
21+}
22+
23+func (h *handlerSet) RunContextMenu(
24+ _ purecef.Browser, _ purecef.Frame,
25+ params purecef.ContextMenuParams, model purecef.MenuModel,
26+ callback purecef.RunContextMenuCallback,
27+) int32 {
28+ count := model.GetCount()
29+ if count == 0 {
30+ callback.Cancel()
31+ return 1
32+ }
33+
34+ // Snapshot menu items — model/params pointers are only valid during this call.
35+ items := make([]menuEntry, 0, count)
36+ for i := range count {
37+ t := model.GetTypeAt(i)
38+ if t == purecef.MenuItemTypeMenuitemtypeSeparator {
39+ items = append(items, menuEntry{isSep: true})
40+ continue
41+ }
42+ if t == purecef.MenuItemTypeMenuitemtypeCommand {
43+ items = append(items, menuEntry{
44+ label: model.GetLabelAt(i),
45+ commandID: model.GetCommandIDAt(i),
46+ })
47+ }
48+ }
49+ if len(items) == 0 {
50+ callback.Cancel()
51+ return 1
52+ }
53+
54+ x := params.GetXcoord()
55+ y := params.GetYcoord()
56+
57+ h.wv.runOnGTK(func() {
58+ showContextMenu(h.wv, items, x, y, callback)
59+ })
60+ return 1
61+}
62+
63+func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) {
64+ glArea := wv.pipeline.glArea
65+ if glArea == nil {
66+ callback.Cancel()
67+ return
68+ }
69+
70+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
71+ box.AddCssClass("context-menu")
72+
73+ selected := false
74+ var popover *gtk.Popover
75+
76+ for _, item := range items {
77+ if item.isSep {
78+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
79+ box.Append(&sep.Widget)
80+ continue
81+ }
82+
83+ btn := gtk.NewButton()
84+ btn.SetLabel(item.label)
85+ btn.AddCssClass("flat")
86+
87+ cmdID := item.commandID
88+ // clickCb captures popover by reference; this is safe because the
89+ // button cannot be clicked until after Popup() is called below,
90+ // at which point popover is fully initialized.
91+ clickCb := func(_ gtk.Button) {
92+ selected = true
93+ callback.Cont(cmdID, 0)
94+ if popover != nil {
95+ popover.Popdown()
96+ }
97+ }
98+ btn.ConnectClicked(&clickCb)
99+ box.Append(&btn.Widget)
100+ }
101+
102+ popover = gtk.NewPopover()
103+ popover.SetChild(&box.Widget)
104+ popover.SetParent(&glArea.Widget)
105+ popover.SetHasArrow(false)
106+ popover.SetAutohide(true)
107+
108+ rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
109+ popover.SetPointingTo(rect)
110+
111+ closedCb := func(_ gtk.Popover) {
112+ if !selected {
113+ callback.Cancel()
114+ }
115+ popover.Unparent()
116+ }
117+ popover.ConnectClosed(&closedCb)
118+
119+ popover.Popup()
120+}
121+
122+func (h *handlerSet) OnContextMenuCommand(
123+ _ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams,
124+ _ int32, _ purecef.EventFlags,
125+) int32 {
126+ return 0
127+}
128+
129+func (h *handlerSet) OnContextMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}
130+
131+func (h *handlerSet) RunQuickMenu(
132+ _ purecef.Browser, _ purecef.Frame, _ *purecef.Point, _ *purecef.Size,
133+ _ purecef.QuickMenuEditStateFlags, _ purecef.RunQuickMenuCallback,
134+) int32 {
135+ return 0
136+}
137+
138+func (h *handlerSet) OnQuickMenuCommand(_ purecef.Browser, _ purecef.Frame, _ int32, _ purecef.EventFlags) int32 {
139+ return 0
140+}
141+
142+func (h *handlerSet) OnQuickMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}
A internal/infrastructure/cef/doc.go +2 -0
@@ -0,0 +1,2 @@
1+// Package cef implements port.Engine using the Chromium Embedded Framework via purego-cef.
2+package cef
A internal/infrastructure/cef/engine.go +369 -0
@@ -0,0 +1,369 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// Compile-time interface check.
16+var _ port.Engine = (*Engine)(nil)
17+
18+// Engine implements port.Engine for the CEF browser backend.
19+// It manages the CEF lifecycle and provides access to all engine subsystems.
20+type Engine struct {
21+ ctx context.Context
22+ gl *glLoader
23+ factory *WebViewFactory
24+ pool *WebViewPool
25+
26+ messageRouter *MessageRouter
27+ schemeHandler *dumbSchemeHandler
28+ contentInj *contentInjector
29+
30+ registerHandlers HandlerRegistrar
31+ registerAccentHandlers AccentHandlerRegistrar
32+ currentConfigPayload func() ([]byte, error)
33+ defaultConfigPayload func() ([]byte, error)
34+ mediaClassifier MediaClassifier
35+
36+ // activeWebViews tracks all live webviews for CSS broadcast.
37+ activeWebViews sync.Map // map[port.WebViewID]*WebView
38+ activeCount atomic.Int32
39+
40+ // shutdownNotify is signaled when the active webview count reaches 0
41+ // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
42+ shutdownNotify chan struct{}
43+
44+ // Diagnostic counters.
45+ contextInitializedCount atomic.Uint64
46+ childLaunchRenderer atomic.Uint64
47+ childLaunchGPU atomic.Uint64
48+ childLaunchOther atomic.Uint64
49+
50+ browserCreateRequests atomic.Uint64
51+ browserAfterCreated atomic.Uint64
52+ browserCreateLastAtMs atomic.Int64
53+ browserCreateLastResult atomic.Int32
54+ browserCreateLastWidth atomic.Int32
55+ browserCreateLastHeight atomic.Int32
56+ browserCreateComplete atomic.Bool
57+
58+ transcoderState transcoderStartupState
59+ transcoderStateLogged atomic.Bool
60+}
61+
62+type transcoderStartupState struct {
63+ ConfigEnabled bool
64+ ProbeAttempted bool
65+ HWAccel string
66+ MaxConcurrent int
67+ Quality string
68+ Status string
69+ API string
70+ Encoders []string
71+ Decoders []string
72+}
73+
74+func (e *Engine) Factory() port.WebViewFactory {
75+ return &webViewFactoryAdapter{factory: e.factory}
76+}
77+
78+func (e *Engine) Pool() port.WebViewPool {
79+ return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
80+}
81+
82+func (e *Engine) ContentInjector() port.ContentInjector {
83+ if e.contentInj != nil {
84+ return e.contentInj
85+ }
86+ return &noopContentInjector{}
87+}
88+
89+// SettingsApplier returns a no-op applier (Phase 1 stub).
90+func (e *Engine) SettingsApplier() port.SettingsApplier {
91+ return &noopSettingsApplier{}
92+}
93+
94+// FilterApplier returns nil (content filtering not yet supported for CEF).
95+func (e *Engine) FilterApplier() port.FilterApplier {
96+ return nil
97+}
98+
99+// FaviconDatabase returns a no-op database (Phase 1 stub).
100+func (e *Engine) FaviconDatabase() port.FaviconDatabase {
101+ return &noopFaviconDatabase{}
102+}
103+
104+// SetColorResolver sets the color scheme resolver on the content injector.
105+// This allows dark mode detection for internal pages. Safe to call after
106+// engine creation (e.g., from bootstrap wiring).
107+func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
108+ if e.contentInj != nil {
109+ e.contentInj.setColorResolver(resolver)
110+ }
111+}
112+
113+// registerWebView adds a webview to the active tracking map.
114+func (e *Engine) registerWebView(wv *WebView) {
115+ e.activeWebViews.Store(wv.id, wv)
116+ e.activeCount.Add(1)
117+}
118+
119+// unregisterWebView removes a webview from the active tracking map.
120+// If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
121+func (e *Engine) unregisterWebView(wv *WebView) {
122+ e.activeWebViews.Delete(wv.id)
123+ if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
124+ select {
125+ case e.shutdownNotify <- struct{}{}:
126+ default:
127+ }
128+ }
129+}
130+
131+// internalSchemePath is the URI scheme used for internal app resources.
132+const internalSchemePath = "dumb://"
133+
134+// InternalSchemePath returns the URI scheme used for internal app resources.
135+func (e *Engine) InternalSchemePath() string {
136+ return internalSchemePath
137+}
138+
139+// Close releases all resources held by the engine.
140+func (e *Engine) Close() error {
141+ log := logging.FromContext(e.ctx)
142+ activeBefore := e.activeWebViewCount()
143+ log.Debug().
144+ Int("active_webviews", activeBefore).
145+ Msg("cef: engine close started")
146+
147+ e.closeActiveWebViews()
148+ e.pool.Close()
149+
150+ if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
151+ log.Warn().
152+ Int("remaining_active_webviews", activeAfter).
153+ Msg("cef: shutting down with active webviews still registered")
154+ }
155+
156+ purecef.Shutdown()
157+ log.Debug().Msg("cef: engine close completed")
158+ return nil
159+}
160+
161+const cefShutdownWaitTimeout = 2 * time.Second
162+
163+func (e *Engine) activeWebViewCount() int {
164+ return int(e.activeCount.Load())
165+}
166+
167+func (e *Engine) closeActiveWebViews() {
168+ if e == nil {
169+ return
170+ }
171+
172+ log := logging.FromContext(e.ctx)
173+ e.shutdownNotify = make(chan struct{}, 1)
174+ webViews := make([]*WebView, 0, e.activeWebViewCount())
175+ e.activeWebViews.Range(func(_, value any) bool {
176+ wv, ok := value.(*WebView)
177+ if ok && wv != nil {
178+ webViews = append(webViews, wv)
179+ }
180+ return true
181+ })
182+
183+ if len(webViews) == 0 {
184+ return
185+ }
186+
187+ log.Debug().
188+ Int("count", len(webViews)).
189+ Msg("cef: closing active webviews before shutdown")
190+
191+ for _, wv := range webViews {
192+ wv.Destroy()
193+ }
194+
195+ // Check immediately in case all webviews closed synchronously.
196+ if e.activeWebViewCount() == 0 {
197+ log.Debug().Msg("cef: all active webviews closed before shutdown")
198+ return
199+ }
200+
201+ // Wait for unregisterWebView to signal that all webviews are gone, or timeout.
202+ select {
203+ case <-e.shutdownNotify:
204+ log.Debug().Msg("cef: all active webviews closed before shutdown")
205+ case <-time.After(cefShutdownWaitTimeout):
206+ log.Warn().
207+ Int("remaining", e.activeWebViewCount()).
208+ Str("timeout", cefShutdownWaitTimeout.String()).
209+ Msg("cef: timed out waiting for OnBeforeClose before shutdown")
210+ }
211+}
212+
213+// RegisterHandlers registers all WebUI message bridge handlers with the message router.
214+// Handler registration follows the same pattern as WebKit's handlers/register.go:
215+// each handler is registered by message type with the router, which dispatches
216+// incoming /api/message POSTs to the correct handler based on Message.Type.
217+func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
218+ if e.messageRouter == nil || e.registerHandlers == nil {
219+ return nil
220+ }
221+ return e.registerHandlers(ctx, e.messageRouter, deps)
222+}
223+
224+func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
225+ if e.messageRouter == nil || handler == nil || e.registerAccentHandlers == nil {
226+ return nil
227+ }
228+ return e.registerAccentHandlers(ctx, e.messageRouter, handler)
229+}
230+
231+// ConfigureDownloads sets up download handling (Phase 1 stub).
232+func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error {
233+ return ErrDownloadsUnsupported
234+}
235+
236+// OnToolkitReady is called after the UI toolkit has initialized.
237+// With multi-threaded message loop enabled, CEF drives its own pump
238+// and no manual pump is needed.
239+func (e *Engine) OnToolkitReady(_ context.Context) error {
240+ log := logging.FromContext(e.ctx)
241+ log.Debug().Msg("cef: OnToolkitReady called")
242+ return nil
243+}
244+
245+// UpdateAppearance updates the default background color for new WebViews.
246+func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) error {
247+ if e.factory != nil {
248+ e.factory.setDefaultBackgroundColor(r, g, b, alpha)
249+ }
250+ return nil
251+}
252+
253+// UpdateSettings applies runtime config changes to engine internals (Phase 1 stub).
254+func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) error {
255+ return nil
256+}
257+
258+// SetHandlerContext sets the base context for message handler dispatch.
259+func (e *Engine) SetHandlerContext(ctx context.Context) {
260+ e.ctx = ctx
261+ if e.messageRouter != nil {
262+ e.messageRouter.SetBaseContext(ctx)
263+ }
264+ e.logTranscoderStartupState()
265+}
266+
267+func (e *Engine) recordContextInitialized() {
268+ count := e.contextInitializedCount.Add(1)
269+ logging.FromContext(e.ctx).Debug().
270+ Uint64("count", count).
271+ Msg("cef: OnContextInitialized")
272+}
273+
274+func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLine string) {
275+ switch processType {
276+ case "renderer":
277+ e.childLaunchRenderer.Add(1)
278+ case "gpu-process":
279+ e.childLaunchGPU.Add(1)
280+ default:
281+ e.childLaunchOther.Add(1)
282+ }
283+
284+ logging.FromContext(e.ctx).Debug().
285+ Str("process_type", processType).
286+ Str("use_angle", useAngle).
287+ Str("ozone_platform", ozonePlatform).
288+ Str("command_line", commandLine).
289+ Uint64("renderer_launches", e.childLaunchRenderer.Load()).
290+ Uint64("gpu_launches", e.childLaunchGPU.Load()).
291+ Uint64("other_launches", e.childLaunchOther.Load()).
292+ Msg("cef: OnBeforeChildProcessLaunch")
293+}
294+
295+func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
296+ count := e.browserCreateRequests.Add(1)
297+ e.browserCreateLastAtMs.Store(time.Now().UnixMilli())
298+ e.browserCreateLastResult.Store(result)
299+ e.browserCreateLastWidth.Store(width)
300+ e.browserCreateLastHeight.Store(height)
301+
302+ logging.FromContext(e.ctx).Debug().
303+ Uint64("request_count", count).
304+ Int32("width", width).
305+ Int32("height", height).
306+ Int32("result", result).
307+ Msg("cef: BrowserHostCreateBrowser returned")
308+ if result != 1 {
309+ logging.FromContext(e.ctx).Warn().
310+ Uint64("request_count", count).
311+ Int32("width", width).
312+ Int32("height", height).
313+ Int32("result", result).
314+ Msg("cef: BrowserHostCreateBrowser returned non-success")
315+ }
316+}
317+
318+func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
319+ count := e.browserAfterCreated.Add(1)
320+ if count >= e.browserCreateRequests.Load() {
321+ e.browserCreateComplete.Store(true)
322+ }
323+ browserID := int32(0)
324+ if browser != nil {
325+ browserID = browser.GetIdentifier()
326+ }
327+ logging.FromContext(e.ctx).Debug().
328+ Uint64("after_created_count", count).
329+ Uint64("create_requests", e.browserCreateRequests.Load()).
330+ Int32("browser_id", browserID).
331+ Msg("cef: browser created")
332+}
333+
334+func (e *Engine) currentContext() context.Context {
335+ if e == nil || e.ctx == nil {
336+ return context.Background()
337+ }
338+ return e.ctx
339+}
340+
341+func (e *Engine) logTranscoderStartupState() {
342+ if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
343+ return
344+ }
345+
346+ state := e.transcoderState
347+ log := logging.FromContext(e.currentContext())
348+ event := log.Info().
349+ Str("component", "cef-transcoder").
350+ Bool("config_enabled", state.ConfigEnabled).
351+ Bool("probe_attempted", state.ProbeAttempted).
352+ Str("hwaccel", state.HWAccel).
353+ Int("max_concurrent", state.MaxConcurrent).
354+ Str("quality", state.Quality).
355+ Str("status", state.Status).
356+ Bool("request_handler_enabled", e.factory != nil && e.factory.transcoder != nil)
357+
358+ if state.API != "" {
359+ event = event.Str("api", state.API)
360+ }
361+ if len(state.Encoders) > 0 {
362+ event = event.Strs("encoders", state.Encoders)
363+ }
364+ if len(state.Decoders) > 0 {
365+ event = event.Strs("decoders", state.Decoders)
366+ }
367+
368+ event.Msg("cef: transcoder startup state")
369+}
A internal/infrastructure/cef/engine_adapters.go +101 -0
@@ -0,0 +1,101 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// --- WebViewFactory adapter ---
12+
13+// webViewFactoryAdapter bridges *WebViewFactory to port.WebViewFactory.
14+// WebViewFactory.Create and CreateRelated return port.WebView directly,
15+// so this adapter is thin but ensures the concrete type stays private.
16+type webViewFactoryAdapter struct {
17+ factory *WebViewFactory
18+}
19+
20+func (a *webViewFactoryAdapter) Create(ctx context.Context) (port.WebView, error) {
21+ return a.factory.Create(ctx)
22+}
23+
24+func (a *webViewFactoryAdapter) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
25+ return a.factory.CreateRelated(ctx, parentID)
26+}
27+
28+// --- WebViewPool adapter ---
29+
30+// webViewPoolAdapter bridges *WebViewPool to port.WebViewPool.
31+type webViewPoolAdapter struct {
32+ pool *WebViewPool
33+ ctx context.Context
34+}
35+
36+func (a *webViewPoolAdapter) Acquire(ctx context.Context) (port.WebView, error) {
37+ wv, err := a.pool.Acquire(ctx)
38+ if err != nil {
39+ return nil, err
40+ }
41+ return wv, nil
42+}
43+
44+func (a *webViewPoolAdapter) Release(wv port.WebView) {
45+ if wv == nil {
46+ return
47+ }
48+ cwv, ok := wv.(*WebView)
49+ if !ok {
50+ logging.FromContext(a.ctx).Warn().
51+ Str("concrete_type", fmt.Sprintf("%T", wv)).
52+ Msg("webViewPoolAdapter.Release: unexpected type, cannot release to pool")
53+ return
54+ }
55+ a.pool.Release(cwv)
56+}
57+
58+func (a *webViewPoolAdapter) Prewarm(count int) {
59+ a.pool.Prewarm(count)
60+}
61+
62+func (a *webViewPoolAdapter) PrewarmAsync(ctx context.Context, count int) {
63+ a.pool.PrewarmAsync(ctx, count)
64+}
65+
66+func (a *webViewPoolAdapter) Size() int {
67+ return a.pool.Size()
68+}
69+
70+func (a *webViewPoolAdapter) Close() {
71+ a.pool.Close()
72+}
73+
74+// --- No-op stubs for Phase 1 ---
75+
76+// noopContentInjector implements port.ContentInjector as a no-op.
77+type noopContentInjector struct{}
78+
79+func (n *noopContentInjector) InjectThemeCSS(_ context.Context, _ string) error {
80+ return nil
81+}
82+
83+func (n *noopContentInjector) InjectFindHighlightCSS(_ context.Context, _ string) error {
84+ return nil
85+}
86+
87+func (n *noopContentInjector) RefreshScripts(_ context.Context, _ port.WebView) error {
88+ return nil
89+}
90+
91+// noopSettingsApplier implements port.SettingsApplier as a no-op.
92+type noopSettingsApplier struct{}
93+
94+func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {}
95+
96+// noopFaviconDatabase implements port.FaviconDatabase as a no-op.
97+type noopFaviconDatabase struct{}
98+
99+func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {
100+ callback(nil)
101+}
A internal/infrastructure/cef/engine_contract_test.go +22 -0
@@ -0,0 +1,22 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) {
12+ eng := &Engine{}
13+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
14+ require.ErrorIs(t, err, ErrDownloadsUnsupported)
15+}
16+
17+func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
18+ factory := &WebViewFactory{}
19+ wv, err := factory.CreateRelated(context.Background(), port.WebViewID(42))
20+ require.Nil(t, wv)
21+ require.ErrorIs(t, err, ErrRelatedWebViewUnsupported)
22+}
A internal/infrastructure/cef/engine_init.go +477 -0
@@ -0,0 +1,477 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "syscall"
11+
12+ purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/glib"
15+ "github.com/rs/zerolog"
16+
17+ "github.com/bnema/dumber/assets"
18+ "github.com/bnema/dumber/internal/application/port"
19+ "github.com/bnema/dumber/internal/infrastructure/config"
20+ transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
21+ "github.com/bnema/dumber/internal/logging"
22+)
23+
24+const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
25+
26+// NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
27+func NewEngine(
28+ ctx context.Context,
29+ opts port.EngineOptions,
30+ cfg RuntimeConfig,
31+ transcodingCfg TranscodingRuntimeConfig,
32+ audioFactory port.AudioOutputFactory,
33+ deps EngineDependencies,
34+) (*Engine, error) {
35+ logger := logging.FromContext(ctx)
36+ deps.MediaClassifier = deps.MediaClassifier.normalize()
37+ stateRoot := resolvedStateRoot(opts)
38+ cleanStaleSingletonLocks(logger, stateRoot)
39+ windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
40+
41+ settings, err := prepareCEFSettings(opts, cfg, logger)
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ // Inject --no-zygote temporarily for cef_initialize, then restore.
47+ // Safe: runs during single-threaded startup before concurrent goroutines.
48+ savedArgs := os.Args
49+ os.Args = appendIfMissing(os.Args, "--no-zygote")
50+
51+ eng := &Engine{
52+ ctx: ctx,
53+ registerHandlers: deps.RegisterHandlers,
54+ registerAccentHandlers: deps.RegisterAccentHandlers,
55+ currentConfigPayload: deps.CurrentConfigPayload,
56+ defaultConfigPayload: deps.DefaultConfigPayload,
57+ mediaClassifier: deps.MediaClassifier,
58+ }
59+
60+ logger.Info().
61+ Int32("windowless_frame_rate", windowlessFrameRate).
62+ Bool("external_begin_frame", externalBeginFrameEnabled()).
63+ Bool("trace_handlers", cfg.TraceHandlers).
64+ Bool("enable_audio_handler", cfg.EnableAudioHandler).
65+ Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
66+ Msg("cef: configured engine")
67+
68+ if err := initializeCEF(eng, settings, logger); err != nil {
69+ os.Args = savedArgs
70+ return nil, err
71+ }
72+ os.Args = savedArgs
73+
74+ return wireEngine(
75+ ctx,
76+ eng,
77+ cfg,
78+ transcodingCfg,
79+ windowlessFrameRate,
80+ audioFactory,
81+ logger,
82+ deps.MediaClassifier,
83+ deps.CurrentConfigPayload,
84+ deps.DefaultConfigPayload,
85+ )
86+}
87+
88+func resolvedStateRoot(opts port.EngineOptions) string {
89+ switch {
90+ case opts.DataDir != "":
91+ return opts.DataDir
92+ case opts.CacheDir != "":
93+ return opts.CacheDir
94+ default:
95+ return defaultCEFUserDataDir()
96+ }
97+}
98+
99+// prepareCEFSettings builds purecef.Settings from the engine config.
100+func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) {
101+ switch opts.CookiePolicy {
102+ case "", port.CookiePolicyAlways:
103+ case port.CookiePolicyNoThirdParty:
104+ logger.Warn().
105+ Str("cookie_policy", string(opts.CookiePolicy)).
106+ Msg("cef: no_third_party cookie policy is currently treated as always")
107+ case port.CookiePolicyNever:
108+ return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
109+ default:
110+ return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
111+ }
112+
113+ settings := purecef.DefaultSettings()
114+ settings.MultiThreadedMessageLoop = true
115+ settings.ExternalMessagePump = false
116+ settings.RootCachePath = resolvedStateRoot(opts)
117+ if cfg.CEFDir != "" {
118+ settings.CEFDir = cfg.CEFDir
119+ }
120+ if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil {
121+ logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
122+ } else {
123+ settings.LogFile = runtimeLogFile
124+ }
125+ if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil {
126+ logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
127+ } else if bootstrapLogFile != "" {
128+ settings.InitTraceFile = bootstrapLogFile
129+ logger.Info().
130+ Str("bootstrap_log_file", bootstrapLogFile).
131+ Str("env_var", puregoCEFInitTraceEnvVar).
132+ Msg("cef: init bootstrap diagnostics enabled")
133+ }
134+ if cfg.LogSeverity != 0 {
135+ settings.LogSeverity = cfg.LogSeverity
136+ }
137+ if helperPath := findHelperBinary(); helperPath != "" {
138+ settings.BrowserSubprocessPath = helperPath
139+ logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
140+ } else {
141+ logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
142+ }
143+ return settings, nil
144+}
145+
146+func defaultCEFUserDataDir() string {
147+ // Use Dumber's XDG helpers to get the config directory
148+ dirs, err := config.GetXDGDirs()
149+ if err != nil {
150+ // Fallback to HOME-based path if XDG resolution fails
151+ return filepath.Clean(filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data"))
152+ }
153+ if os.Getenv("ENV") == "dev" {
154+ return filepath.Join(dirs.ConfigHome, "cef_user_data")
155+ }
156+ return filepath.Join(filepath.Dir(dirs.ConfigHome), "cef_user_data")
157+}
158+
159+// initializeCEF calls cef_initialize with the App to register custom schemes
160+// and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
161+func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
162+ app := newDumberApp(eng)
163+ logger.Debug().Msg("cef: calling InitWithApp")
164+ if err := purecef.InitWithApp(settings, app); err != nil {
165+ return fmt.Errorf("cef.InitWithApp: %w", err)
166+ }
167+ logger.Debug().Msg("cef: InitWithApp returned OK")
168+ return nil
169+}
170+
171+// wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
172+func wireEngine(
173+ ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
174+ windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
175+ mediaClassifier MediaClassifier,
176+ currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
177+) (*Engine, error) {
178+ gl, err := newGLLoader()
179+ if err != nil {
180+ purecef.Shutdown()
181+ return nil, fmt.Errorf("GL loader: %w", err)
182+ }
183+
184+ scale := detectHiDPIScale(logger)
185+
186+ // Initialize the GPU transcoder when enabled in the loaded app config.
187+ var mediaTranscoder port.MediaTranscoder
188+ transcoderState := buildTranscoderStartupState(transcodingCfg)
189+ if transcodingCfg.Enabled {
190+ transcoderState.ProbeAttempted = true
191+ tc := transcoderpkg.New(config.TranscodingConfig{
192+ Enabled: transcodingCfg.Enabled,
193+ HWAccel: transcodingCfg.HWAccel,
194+ MaxConcurrent: transcodingCfg.MaxConcurrent,
195+ Quality: transcodingCfg.Quality,
196+ }, logger)
197+ caps := tc.Capabilities()
198+ transcoderState.API = caps.API
199+ transcoderState.Encoders = append([]string(nil), caps.Encoders...)
200+ transcoderState.Decoders = append([]string(nil), caps.Decoders...)
201+ if tc.Available() {
202+ mediaTranscoder = tc
203+ transcoderState.Status = "available"
204+ logger.Info().
205+ Str("api", tc.Capabilities().API).
206+ Strs("encoders", tc.Capabilities().Encoders).
207+ Msg("cef: GPU transcoding available")
208+ } else {
209+ transcoderState.Status = "unavailable_no_compatible_gpu"
210+ logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled")
211+ }
212+ }
213+
214+ factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
215+ scale: scale,
216+ windowlessFrameRate: windowlessFrameRate,
217+ enableContextMenuHandler: cfg.EnableContextMenuHandler,
218+ transcoder: mediaTranscoder,
219+ mediaClassifier: mediaClassifier,
220+ audioOutputFactory: audioFactory,
221+ })
222+ pool := newWebViewPool(factory)
223+
224+ eng.gl = gl
225+ eng.factory = factory
226+ eng.pool = pool
227+ eng.contentInj = newContentInjector(eng, nil)
228+ eng.transcoderState = transcoderState
229+
230+ messageRouter := NewMessageRouter(ctx)
231+ schemeHandler, err := newDumbSchemeHandler(
232+ ctx,
233+ messageRouter,
234+ mediaTranscoder,
235+ currentConfigPayload,
236+ defaultConfigPayload,
237+ )
238+ if err != nil {
239+ purecef.Shutdown()
240+ return nil, err
241+ }
242+ schemeHandler.setAssets(assets.WebUIAssets)
243+
244+ // Bridge clipboard writes from CEF JS → GDK system clipboard.
245+ // The callback is invoked on the CEF IO thread, so we schedule the
246+ // GDK write on the GTK main loop via glib.IdleAddOnce.
247+ schemeHandler.onClipboardSet = func(text string) {
248+ fn := glib.SourceOnceFunc(func(_ uintptr) {
249+ if display := gdk.DisplayGetDefault(); display != nil {
250+ if cb := display.GetClipboard(); cb != nil {
251+ cb.SetText(text)
252+ logger.Debug().Msg("cef: clipboard set via GDK")
253+ } else {
254+ logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
255+ }
256+ } else {
257+ logger.Debug().Msg("cef: clipboard set failed — no GDK display")
258+ }
259+ })
260+ glib.IdleAddOnce(&fn, 0)
261+ }
262+
263+ schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
264+
265+ result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
266+ if result != 1 {
267+ logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
268+ } else {
269+ logger.Info().Msg("cef: registered dumb:// scheme handler factory")
270+ }
271+
272+ result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
273+ if result != 1 {
274+ logger.Error().
275+ Int32("result", result).
276+ Str("origin", actualInternalOrigin).
277+ Msg("cef: failed to register internal https handler factory")
278+ } else {
279+ logger.Info().
280+ Str("origin", actualInternalOrigin).
281+ Msg("cef: registered internal https handler factory")
282+ }
283+
284+ eng.messageRouter = messageRouter
285+ eng.schemeHandler = schemeHandler
286+
287+ return eng, nil
288+}
289+
290+func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState {
291+ hwaccel := cfg.HWAccel
292+ if hwaccel == "" {
293+ hwaccel = "auto"
294+ }
295+
296+ maxConcurrent := cfg.MaxConcurrent
297+ if maxConcurrent <= 0 {
298+ maxConcurrent = 2
299+ }
300+
301+ quality := cfg.Quality
302+ if quality == "" {
303+ quality = "medium"
304+ }
305+
306+ status := "disabled_in_config"
307+ if cfg.Enabled {
308+ status = "configured_enabled"
309+ }
310+
311+ return transcoderStartupState{
312+ ConfigEnabled: cfg.Enabled,
313+ HWAccel: hwaccel,
314+ MaxConcurrent: maxConcurrent,
315+ Quality: quality,
316+ Status: status,
317+ }
318+}
319+
320+// getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable.
321+func getPrimaryMonitor() *gdk.Monitor {
322+ display := gdk.DisplayGetDefault()
323+ if display == nil {
324+ return nil
325+ }
326+ monitors := display.GetMonitors()
327+ if monitors == nil {
328+ return nil
329+ }
330+ obj := monitors.GetObject(0)
331+ if obj == nil {
332+ return nil
333+ }
334+ mon := &gdk.Monitor{}
335+ mon.SetGoPointer(obj.GoPointer())
336+ return mon
337+}
338+
339+// detectHiDPIScale queries the primary GDK monitor for its scale factor.
340+func detectHiDPIScale(logger *zerolog.Logger) int32 {
341+ mon := getPrimaryMonitor()
342+ if mon == nil {
343+ logger.Debug().Msg("cef: no primary monitor, using scale=1")
344+ return 1
345+ }
346+ if s := mon.GetScaleFactor(); s > 0 {
347+ logger.Info().Int32("scale", int32(s)).Msg("cef: detected HiDPI scale from monitor")
348+ return int32(s)
349+ }
350+ logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
351+ return 1
352+}
353+
354+// appendIfMissing appends flag to args if it's not already present.
355+func appendIfMissing(args []string, flag string) []string {
356+ for _, a := range args {
357+ if a == flag {
358+ return args
359+ }
360+ }
361+ return append(args, flag)
362+}
363+
364+// cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
365+// if the owning process is no longer running. CEF leaves these behind on
366+// unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
367+// to connect to the dead process.
368+func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
369+ if dir == "" {
370+ return
371+ }
372+
373+ lockPath := filepath.Join(dir, "SingletonLock")
374+ target, err := os.Readlink(lockPath)
375+ if err != nil {
376+ return // No lock file or not a symlink — nothing to clean.
377+ }
378+
379+ // SingletonLock is a symlink to "hostname-pid". Split from the right
380+ // because hostnames may contain hyphens.
381+ lastDash := strings.LastIndex(target, "-")
382+ if lastDash < 0 || lastDash == len(target)-1 {
383+ return
384+ }
385+ pid, err := strconv.Atoi(target[lastDash+1:])
386+ if err != nil || pid <= 0 {
387+ return
388+ }
389+
390+ // Check if the process is still alive.
391+ proc, err := os.FindProcess(pid)
392+ if err == nil {
393+ if err := proc.Signal(syscall.Signal(0)); err == nil {
394+ // Process is alive — don't touch the lock.
395+ return
396+ }
397+ }
398+
399+ // Stale lock — remove singleton files.
400+ for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
401+ p := filepath.Join(dir, name)
402+ if err := os.Remove(p); err == nil {
403+ logger.Info().Str("path", p).Msg("cef: removed stale singleton file")
404+ }
405+ }
406+}
407+
408+// findHelperBinary looks for the cef-helper binary next to the running
409+// executable. Returns empty string if not found.
410+func findHelperBinary() string {
411+ exe, err := os.Executable()
412+ if err != nil {
413+ return ""
414+ }
415+ dir := filepath.Dir(exe)
416+ helper := filepath.Join(dir, "cef-helper")
417+ if _, err := os.Stat(helper); err == nil {
418+ return helper
419+ }
420+ return ""
421+}
422+
423+const (
424+ dirPerm = 0o755
425+ filePerm = 0o644
426+)
427+
428+func prepareCEFLogFile(logFile string) (string, error) {
429+ if logFile != "" {
430+ runtimeLogFile := filepath.Clean(logFile)
431+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
432+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
433+ }
434+ return runtimeLogFile, nil
435+ }
436+
437+ // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD.
438+ // Production deployments should set cfg.LogFile explicitly.
439+ cwd, cwdErr := os.Getwd()
440+ if cwdErr != nil {
441+ return "", fmt.Errorf("getwd: %w", cwdErr)
442+ }
443+ runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
444+ if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
445+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
446+ }
447+ return runtimeLogFile, nil
448+}
449+
450+func prepareCEFInitTraceFile(logFile string) (string, error) {
451+ if !puregoCEFInitTraceEnabled() {
452+ return "", nil
453+ }
454+
455+ runtimeLogFile := logFile
456+ if runtimeLogFile != "" {
457+ runtimeLogFile = filepath.Clean(runtimeLogFile)
458+ } else {
459+ cwd, cwdErr := os.Getwd()
460+ if cwdErr != nil {
461+ return "", fmt.Errorf("getwd: %w", cwdErr)
462+ }
463+ runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
464+ }
465+ bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
466+ if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
467+ return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)
468+ }
469+ if err := os.WriteFile(bootstrapLogFile, nil, filePerm); err != nil {
470+ return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
471+ }
472+ return bootstrapLogFile, nil
473+}
474+
475+func puregoCEFInitTraceEnabled() bool {
476+ return envBoolEnabled(puregoCEFInitTraceEnvVar)
477+}
A internal/infrastructure/cef/engine_init_test.go +106 -0
@@ -0,0 +1,106 @@
1+package cef
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {
16+ root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
17+ require.Equal(t, "/tmp/data", root)
18+}
19+
20+func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
21+ root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"})
22+ require.Equal(t, "/tmp/cache", root)
23+}
24+
25+func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
26+ logger := zerolog.Nop()
27+ settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)
28+ require.NoError(t, err)
29+ require.Equal(t, "/tmp/cache", settings.RootCachePath)
30+}
31+
32+func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
33+ logger := zerolog.Nop()
34+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger)
35+ require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
36+}
37+
38+func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
39+ logger := zerolog.Nop()
40+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger)
41+ require.NoError(t, err)
42+}
43+
44+func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
45+ t.Setenv(puregoCEFInitTraceEnvVar, "")
46+
47+ path, err := prepareCEFInitTraceFile("")
48+ require.NoError(t, err)
49+ require.Empty(t, path)
50+}
51+
52+func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
53+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
54+ cfg := config.CEFEngineConfig{
55+ LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
56+ }
57+
58+ path, err := prepareCEFInitTraceFile(cfg.LogFile)
59+ require.NoError(t, err)
60+ require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path)
61+ require.FileExists(t, path)
62+}
63+
64+func TestPrepareCEFSettings_RootCachePath_UsesLegacyCEFUserDataDir(t *testing.T) {
65+ homeDir := filepath.Join(t.TempDir(), "home")
66+ t.Setenv("HOME", homeDir)
67+ t.Setenv("ENV", "")
68+
69+ logger := zerolog.Nop()
70+ settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
71+ require.NoError(t, err)
72+
73+ require.Equal(t, filepath.Join(homeDir, ".config", "cef_user_data"), settings.RootCachePath)
74+}
75+
76+func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
77+ // Create a temp directory and change into it for the test
78+ tempDir := t.TempDir()
79+ originalDir, err := os.Getwd()
80+ require.NoError(t, err)
81+ defer func() {
82+ _ = os.Chdir(originalDir)
83+ }()
84+
85+ err = os.Chdir(tempDir)
86+ require.NoError(t, err)
87+
88+ // Set ENV=dev to trigger dev mode
89+ t.Setenv("ENV", "dev")
90+
91+ logger := zerolog.Nop()
92+ settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
93+ require.NoError(t, err)
94+
95+ // In dev mode, should resolve under .dev/dumber/cef_user_data
96+ require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath)
97+}
98+
99+// TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
100+// factory can be created and produces working streams. This tests the runtime
101+// wiring decision to create the factory during CEF engine setup.
102+func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
103+ audioFactory := factory.NewAudioOutputFactory()
104+
105+ require.NotNil(t, audioFactory, "Audio factory should not be nil")
106+}
A internal/infrastructure/cef/engine_runtime_test.go +53 -0
@@ -0,0 +1,53 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type stubAccentHandler struct{}
13+
14+func (stubAccentHandler) OnKeyPressed(context.Context, rune, bool) bool { return true }
15+func (stubAccentHandler) OnKeyReleased(context.Context, rune) {}
16+
17+func TestEngineRegisterHandlers_UsesInjectedRegistrar(t *testing.T) {
18+ called := false
19+ eng := &Engine{
20+ messageRouter: NewMessageRouter(context.Background()),
21+ registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error {
22+ called = true
23+ return nil
24+ },
25+ }
26+ require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}))
27+ require.True(t, called)
28+}
29+
30+func TestEngineRegisterAccentHandlers_UsesInjectedRegistrar(t *testing.T) {
31+ called := false
32+ eng := &Engine{
33+ messageRouter: NewMessageRouter(context.Background()),
34+ registerAccentHandlers: func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error {
35+ called = true
36+ return nil
37+ },
38+ }
39+ require.NoError(t, eng.RegisterAccentHandlers(context.Background(), stubAccentHandler{}))
40+ require.True(t, called)
41+}
42+
43+func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) {
44+ want := errors.New("boom")
45+ eng := &Engine{
46+ messageRouter: NewMessageRouter(context.Background()),
47+ registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error {
48+ return want
49+ },
50+ }
51+ err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{})
52+ require.ErrorIs(t, err, want)
53+}
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+}
A internal/infrastructure/cef/env_test.go +34 -0
@@ -0,0 +1,34 @@
1+package cef
2+
3+import "testing"
4+
5+func TestExternalBeginFrameEnabledIsOptIn(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ value string
9+ want bool
10+ }{
11+ {name: "1", value: "1", want: true},
12+ {name: "true", value: "true", want: true},
13+ {name: "yes", value: "yes", want: true},
14+ {name: "on", value: "on", want: true},
15+ {name: "TRUE", value: "TRUE", want: true},
16+ {name: " true ", value: " true ", want: true},
17+ {name: "", value: "", want: false},
18+ {name: "false", value: "false", want: false},
19+ {name: "0", value: "0", want: false},
20+ {name: "no", value: "no", want: false},
21+ {name: "off", value: "off", want: false},
22+ {name: "random", value: "random", want: false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ t.Setenv(cefExternalBeginFrameEnvVar, tt.value)
28+
29+ if got := envBoolEnabled(cefExternalBeginFrameEnvVar); got != tt.want {
30+ t.Fatalf("envBoolEnabled(%q) = %v, want %v", tt.value, got, tt.want)
31+ }
32+ })
33+ }
34+}
A internal/infrastructure/cef/factory.go +223 -0
@@ -0,0 +1,223 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Compile-time interface check.
14+var _ port.WebViewFactory = (*WebViewFactory)(nil)
15+
16+// WebViewFactory creates new CEF off-screen browser WebViews. Each WebView
17+// gets a unique ID, its own renderPipeline and inputBridge, and an
18+// asynchronously-created CEF browser (via BrowserHostCreateBrowser).
19+type WebViewFactory struct {
20+ engine *Engine
21+ gl *glLoader
22+ nextID atomic.Uint64
23+ scale int32
24+ windowlessFrameRate int32
25+ enableContextMenuHandler bool
26+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27+ transcoder port.MediaTranscoder
28+ mediaClassifier MediaClassifier
29+ audioOutputFactory port.AudioOutputFactory
30+}
31+
32+type webViewFactoryOptions struct {
33+ scale int32
34+ windowlessFrameRate int32
35+ enableContextMenuHandler bool
36+ transcoder port.MediaTranscoder
37+ mediaClassifier MediaClassifier
38+ audioOutputFactory port.AudioOutputFactory
39+}
40+
41+// newWebViewFactory returns a factory that will create WebViews using the
42+// given GL loader and HiDPI scale factor.
43+func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
44+ if opts.scale < 1 {
45+ opts.scale = 1
46+ }
47+ if opts.windowlessFrameRate < 1 {
48+ opts.windowlessFrameRate = 60
49+ }
50+ return &WebViewFactory{
51+ engine: engine,
52+ gl: gl,
53+ scale: opts.scale,
54+ windowlessFrameRate: opts.windowlessFrameRate,
55+ enableContextMenuHandler: opts.enableContextMenuHandler,
56+ transcoder: opts.transcoder,
57+ mediaClassifier: opts.mediaClassifier,
58+ audioOutputFactory: opts.audioOutputFactory,
59+ }
60+}
61+
62+// setDefaultBackgroundColor stores a packed ARGB color for new browser creation.
63+func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
64+ const s = colorScale
65+ argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)
66+ f.bgColor.Store(argb)
67+}
68+
69+// Create builds a new CEF off-screen browser WebView. The browser is created
70+// asynchronously; the returned WebView is usable immediately but navigation
71+// will fail with errNoBrowser until OnAfterCreated fires.
72+func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
73+ id := port.WebViewID(f.nextID.Add(1))
74+
75+ pipeline := newRenderPipeline(ctx, f.gl, f.scale)
76+
77+ wv := &WebView{
78+ id: id,
79+ ctx: ctx,
80+ engine: f.engine,
81+ pipeline: pipeline,
82+ audioOutputFactory: f.audioOutputFactory,
83+ }
84+
85+ var transcodingHandler purecef.ResourceRequestHandler
86+ if f.transcoder != nil {
87+ transcodingHandler = newTranscodingRequestHandler(f.transcoder, f.mediaClassifier, func() context.Context {
88+ if f.engine != nil {
89+ return f.engine.currentContext()
90+ }
91+ return ctx
92+ })
93+ }
94+
95+ handlers := &handlerSet{
96+ wv: wv,
97+ enableContextMenuHandler: f.enableContextMenuHandler,
98+ transcodingHandler: transcodingHandler,
99+ }
100+ wv.handlers = handlers
101+ wv.findCtrl = newFindController()
102+
103+ input := newInputBridge(ctx, f.scale)
104+ input.attachTo(pipeline.glArea)
105+ wv.input = input
106+
107+ // Wire middle-click → new tab using the cached hover URI.
108+ input.onMiddleClick = func(_ string) {
109+ wv.mu.RLock()
110+ hoverURI := wv.lastHoverURI
111+ cb := wv.callbacks
112+ wv.mu.RUnlock()
113+ if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
114+ return
115+ }
116+ uri := hoverURI
117+ wv.runOnGTK(func() {
118+ cb.OnLinkMiddleClick(uri)
119+ })
120+ }
121+
122+ // Build a CEF client backed by our handlerSet.
123+ // Store on WebView to prevent GC collection before CEF AddRef's it.
124+ client := purecef.NewClient(wv.handlers)
125+ wv.client = client
126+
127+ // Configure WindowInfo for off-screen rendering (OSR).
128+ windowInfo := purecef.NewWindowInfo()
129+ windowInfo.WindowlessRenderingEnabled = 1
130+ if externalBeginFrameEnabled() {
131+ windowInfo.ExternalBeginFrameEnabled = 1
132+ }
133+
134+ // Configure BrowserSettings.
135+ settings := purecef.NewBrowserSettings()
136+ settings.WindowlessFrameRate = f.windowlessFrameRate
137+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
138+ if bg := f.bgColor.Load(); bg != 0 {
139+ settings.BackgroundColor = bg
140+ }
141+
142+ // Defer browser creation until the GL area has a non-zero size.
143+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
144+ // is not yet realized at this point.
145+ f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings)
146+
147+ return wv, nil
148+}
149+
150+func (f *WebViewFactory) configureInitialBrowserCreation(
151+ ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.Client,
152+ windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
153+) {
154+ wv.pendingCreate = &pendingBrowserCreate{
155+ windowInfo: windowInfo,
156+ client: client,
157+ settings: settings,
158+ }
159+
160+ pipeline.onFirstResize = func(w, h int32) {
161+ log := logging.FromContext(ctx)
162+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
163+ pc := wv.takePendingCreate()
164+ if pc == nil {
165+ return
166+ }
167+
168+ task := purecef.NewTask(cefTaskFunc(func() {
169+ if wv.destroyed.Load() {
170+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
171+ return
172+ }
173+ result := purecef.BrowserHostCreateBrowser(
174+ pc.windowInfo,
175+ pc.client,
176+ "about:blank",
177+ pc.settings,
178+ nil, // extraInfo
179+ nil, // requestContext
180+ )
181+ if f.engine != nil {
182+ f.engine.recordBrowserCreateRequest(w, h, result)
183+ }
184+ log.Debug().
185+ Int32("result", result).
186+ Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
187+ Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
188+ Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
189+ Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
190+ Bool("client_nil", pc.client == nil).
191+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
192+ }))
193+
194+ postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
195+ if postResult != 1 {
196+ wv.mu.Lock()
197+ if wv.pendingCreate == nil {
198+ wv.pendingCreate = pc
199+ }
200+ wv.mu.Unlock()
201+ log.Error().
202+ Int32("result", postResult).
203+ Msg("cef: failed to post initial browser creation to CEF UI thread")
204+ }
205+ }
206+
207+ // On subsequent resizes, notify CEF so it re-queries GetViewRect.
208+ pipeline.onResizeCB = func(_, _ int32) {
209+ wv.mu.RLock()
210+ host := wv.host
211+ wv.mu.RUnlock()
212+ if host != nil {
213+ host.WasResized()
214+ }
215+ }
216+}
217+
218+// CreateRelated creates a WebView that shares session/cookies with the parent.
219+// TODO(phase2): look up the parent browser by parentID and use the same
220+// request context so cookies and session state are shared.
221+func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
222+ return nil, ErrRelatedWebViewUnsupported
223+}
A internal/infrastructure/cef/find_controller.go +225 -0
@@ -0,0 +1,225 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// Compile-time interface check.
12+var _ port.FindController = (*cefFindController)(nil)
13+
14+// cefFindController implements port.FindController using CEF's BrowserHost.Find API.
15+type cefFindController struct {
16+ mu sync.RWMutex
17+ host purecef.BrowserHost
18+ searchTxt string
19+ matchCase int32 // cached from most recent Search call
20+
21+ // counting tracks whether the current search is a CountMatches request
22+ // so that handleFindResult dispatches to the right callbacks.
23+ counting bool
24+
25+ // Signal callbacks keyed by unique IDs.
26+ nextSignalID uint
27+ onFound map[uint]func(matchCount uint)
28+ onFailed map[uint]func()
29+ onCounted map[uint]func(matchCount uint)
30+}
31+
32+func newFindController() *cefFindController {
33+ return &cefFindController{
34+ onFound: make(map[uint]func(matchCount uint)),
35+ onFailed: make(map[uint]func()),
36+ onCounted: make(map[uint]func(matchCount uint)),
37+ }
38+}
39+
40+func (fc *cefFindController) setHost(host purecef.BrowserHost) {
41+ fc.mu.Lock()
42+ fc.host = host
43+ fc.mu.Unlock()
44+}
45+
46+func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {
47+ fc.mu.Lock()
48+ fc.searchTxt = text
49+ fc.counting = false
50+ mc := matchCaseFromOpts(opts)
51+ fc.matchCase = mc
52+ host := fc.host
53+ fc.mu.Unlock()
54+
55+ if host == nil || text == "" {
56+ return
57+ }
58+ host.Find(text, 1, mc, 0) // forward=1, findnext=0 (new search)
59+}
60+
61+// CountMatches starts a search whose results are dispatched to onCounted callbacks.
62+func (fc *cefFindController) CountMatches(text string, opts port.FindOptions, _ uint) {
63+ fc.mu.Lock()
64+ fc.searchTxt = text
65+ fc.counting = true
66+ mc := matchCaseFromOpts(opts)
67+ fc.matchCase = mc
68+ host := fc.host
69+ fc.mu.Unlock()
70+
71+ if host == nil || text == "" {
72+ return
73+ }
74+ host.Find(text, 1, mc, 0)
75+}
76+
77+// SearchNext continues the search in the forward direction.
78+func (fc *cefFindController) SearchNext() {
79+ fc.mu.RLock()
80+ host := fc.host
81+ text := fc.searchTxt
82+ mc := fc.matchCase
83+ fc.mu.RUnlock()
84+
85+ if host == nil || text == "" {
86+ return
87+ }
88+ host.Find(text, 1, mc, 1) // forward=1, findnext=1
89+}
90+
91+// SearchPrevious continues the search in the backward direction.
92+func (fc *cefFindController) SearchPrevious() {
93+ fc.mu.RLock()
94+ host := fc.host
95+ text := fc.searchTxt
96+ mc := fc.matchCase
97+ fc.mu.RUnlock()
98+
99+ if host == nil || text == "" {
100+ return
101+ }
102+ host.Find(text, 0, mc, 1) // forward=0, findnext=1
103+}
104+
105+func (fc *cefFindController) SearchFinish() {
106+ fc.mu.Lock()
107+ host := fc.host
108+ fc.searchTxt = ""
109+ fc.mu.Unlock()
110+
111+ if host != nil {
112+ host.StopFinding(1)
113+ }
114+}
115+
116+func (fc *cefFindController) GetSearchText() string {
117+ fc.mu.RLock()
118+ defer fc.mu.RUnlock()
119+ return fc.searchTxt
120+}
121+
122+// OnFoundText registers a callback fired when matches are found.
123+func (fc *cefFindController) OnFoundText(callback func(matchCount uint)) uint {
124+ if callback == nil {
125+ return 0
126+ }
127+ fc.mu.Lock()
128+ defer fc.mu.Unlock()
129+ fc.nextSignalID++
130+ id := fc.nextSignalID
131+ fc.onFound[id] = callback
132+ return id
133+}
134+
135+// OnFailedToFindText registers a callback fired when no matches are found.
136+func (fc *cefFindController) OnFailedToFindText(callback func()) uint {
137+ if callback == nil {
138+ return 0
139+ }
140+ fc.mu.Lock()
141+ defer fc.mu.Unlock()
142+ fc.nextSignalID++
143+ id := fc.nextSignalID
144+ fc.onFailed[id] = callback
145+ return id
146+}
147+
148+// OnCountedMatches registers a callback fired with the final match count.
149+func (fc *cefFindController) OnCountedMatches(callback func(matchCount uint)) uint {
150+ if callback == nil {
151+ return 0
152+ }
153+ fc.mu.Lock()
154+ defer fc.mu.Unlock()
155+ fc.nextSignalID++
156+ id := fc.nextSignalID
157+ fc.onCounted[id] = callback
158+ return id
159+}
160+
161+// DisconnectSignal removes a previously registered callback.
162+func (fc *cefFindController) DisconnectSignal(id uint) {
163+ if id == 0 {
164+ return
165+ }
166+ fc.mu.Lock()
167+ defer fc.mu.Unlock()
168+ delete(fc.onFound, id)
169+ delete(fc.onFailed, id)
170+ delete(fc.onCounted, id)
171+}
172+
173+// handleFindResult is called by the FindHandler when CEF reports find results.
174+func (fc *cefFindController) handleFindResult(_, count, _, finalUpdate int32) {
175+ fc.mu.RLock()
176+ counting := fc.counting
177+
178+ // Snapshot callback maps so we can invoke them outside the lock.
179+ var foundCBs []func(matchCount uint)
180+ var failedCBs []func()
181+ var countedCBs []func(matchCount uint)
182+
183+ if count > 0 && !counting {
184+ foundCBs = make([]func(matchCount uint), 0, len(fc.onFound))
185+ for _, cb := range fc.onFound {
186+ foundCBs = append(foundCBs, cb)
187+ }
188+ }
189+ if count == 0 && finalUpdate != 0 && !counting {
190+ failedCBs = make([]func(), 0, len(fc.onFailed))
191+ for _, cb := range fc.onFailed {
192+ failedCBs = append(failedCBs, cb)
193+ }
194+ }
195+ if finalUpdate != 0 && counting {
196+ countedCBs = make([]func(matchCount uint), 0, len(fc.onCounted))
197+ for _, cb := range fc.onCounted {
198+ countedCBs = append(countedCBs, cb)
199+ }
200+ }
201+ fc.mu.RUnlock()
202+
203+ var safeCount uint
204+ if count > 0 {
205+ safeCount = uint(count)
206+ }
207+ for _, cb := range foundCBs {
208+ cb(safeCount)
209+ }
210+ for _, cb := range failedCBs {
211+ cb()
212+ }
213+ for _, cb := range countedCBs {
214+ cb(safeCount)
215+ }
216+}
217+
218+// matchCaseFromOpts converts FindOptions to CEF's matchcase parameter.
219+// CEF: matchcase=1 means case-sensitive, matchcase=0 means case-insensitive.
220+func matchCaseFromOpts(opts port.FindOptions) int32 {
221+ if opts.CaseInsensitive {
222+ return 0
223+ }
224+ return 1
225+}
A internal/infrastructure/cef/gl_loader.go +184 -0
@@ -0,0 +1,184 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "unsafe"
6+
7+ "github.com/ebitengine/purego"
8+)
9+
10+// OpenGL constants for PBO-based texture rendering.
11+const (
12+ glTexture2D = 0x0DE1
13+ glBGRA = 0x80E1
14+ glRGBA = 0x1908
15+ glUnsignedByte = 0x1401
16+
17+ glPixelUnpackBuffer = 0x88EC
18+ glStreamDraw = 0x88E0
19+ glWriteOnly = 0x88B9
20+
21+ glColorBufferBit = 0x00004000
22+ glTriangleStrip = 0x0005
23+
24+ glFragmentShader = 0x8B30
25+ glVertexShader = 0x8B31
26+ glFloat = 0x1406
27+
28+ glArrayBuffer = 0x8892
29+ glStaticDraw = 0x88E4
30+
31+ glLinear = 0x2601
32+ glTextureMinFilter = 0x2801
33+ glTextureMagFilter = 0x2800
34+ glClampToEdge = 0x812F
35+ glTextureWrapS = 0x2802
36+ glTextureWrapT = 0x2803
37+
38+ glUnpackRowLength = 0x0CF2
39+)
40+
41+// glLoader holds dynamically loaded OpenGL function pointers.
42+// All functions are loaded from libGL.so.1 via purego at runtime (no CGo).
43+type glLoader struct {
44+ handle uintptr
45+
46+ // Texture functions
47+ genTextures func(n int32, textures *uint32)
48+ deleteTextures func(n int32, textures *uint32)
49+ bindTexture func(target uint32, texture uint32)
50+ texImage2D func(
51+ target uint32, level, internalformat, width, height, border int32,
52+ format, xtype uint32, pixels unsafe.Pointer,
53+ )
54+ texSubImage2D func(
55+ target uint32, level, xoffset, yoffset, width, height int32,
56+ format, xtype uint32, pixels unsafe.Pointer,
57+ )
58+ texParameteri func(target uint32, pname uint32, param int32)
59+
60+ // PBO / buffer functions
61+ genBuffers func(n int32, buffers *uint32)
62+ deleteBuffers func(n int32, buffers *uint32)
63+ bindBuffer func(target uint32, buffer uint32)
64+ bufferData func(target uint32, size int64, data unsafe.Pointer, usage uint32)
65+ mapBuffer func(target uint32, access uint32) unsafe.Pointer
66+ unmapBuffer func(target uint32) bool
67+
68+ // Shader functions
69+ createShader func(shaderType uint32) uint32
70+ shaderSource func(shader uint32, count int32, source **byte, length *int32)
71+ compileShader func(shader uint32)
72+ deleteShader func(shader uint32)
73+ createProgram func() uint32
74+ attachShader func(program uint32, shader uint32)
75+ linkProgram func(program uint32)
76+ useProgram func(program uint32)
77+ getUniformLocation func(program uint32, name *byte) int32
78+ uniform1i func(location int32, v0 int32)
79+
80+ // VAO / VBO functions
81+ genVertexArrays func(n int32, arrays *uint32)
82+ bindVertexArray func(array uint32)
83+ deleteVertexArrays func(n int32, arrays *uint32)
84+ vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr)
85+ enableVertexAttribArray func(index uint32)
86+
87+ // Pixel store
88+ pixelStorei func(pname uint32, param int32)
89+
90+ // Draw / state functions
91+ clear func(mask uint32)
92+ clearColor func(red float32, green float32, blue float32, alpha float32)
93+ viewport func(x int32, y int32, width int32, height int32)
94+ drawArrays func(mode uint32, first int32, count int32)
95+ enable func(cap uint32)
96+ disable func(cap uint32)
97+}
98+
99+// Close releases the OpenGL library handle.
100+func (gl *glLoader) Close() error {
101+ if gl == nil {
102+ return nil
103+ }
104+ if gl.handle != 0 {
105+ err := purego.Dlclose(gl.handle)
106+ gl.handle = 0
107+ return err
108+ }
109+ return nil
110+}
111+
112+// newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers.
113+func newGLLoader() (*glLoader, error) {
114+ handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY)
115+ if err != nil {
116+ return nil, fmt.Errorf("failed to open libGL.so.1: %w", err)
117+ }
118+
119+ gl := &glLoader{handle: handle}
120+
121+ if err := gl.registerAll(); err != nil {
122+ return nil, fmt.Errorf("failed to register GL functions: %w", err)
123+ }
124+
125+ return gl, nil
126+}
127+
128+// registerAll loads every GL function pointer from the shared library.
129+// It wraps RegisterLibFunc panics into errors for graceful handling.
130+func (gl *glLoader) registerAll() (retErr error) {
131+ defer func() {
132+ if r := recover(); r != nil {
133+ retErr = fmt.Errorf("GL symbol lookup failed: %v", r)
134+ }
135+ }()
136+
137+ // Texture
138+ purego.RegisterLibFunc(&gl.genTextures, gl.handle, "glGenTextures")
139+ purego.RegisterLibFunc(&gl.deleteTextures, gl.handle, "glDeleteTextures")
140+ purego.RegisterLibFunc(&gl.bindTexture, gl.handle, "glBindTexture")
141+ purego.RegisterLibFunc(&gl.texImage2D, gl.handle, "glTexImage2D")
142+ purego.RegisterLibFunc(&gl.texSubImage2D, gl.handle, "glTexSubImage2D")
143+ purego.RegisterLibFunc(&gl.texParameteri, gl.handle, "glTexParameteri")
144+
145+ // PBO / buffers
146+ purego.RegisterLibFunc(&gl.genBuffers, gl.handle, "glGenBuffers")
147+ purego.RegisterLibFunc(&gl.deleteBuffers, gl.handle, "glDeleteBuffers")
148+ purego.RegisterLibFunc(&gl.bindBuffer, gl.handle, "glBindBuffer")
149+ purego.RegisterLibFunc(&gl.bufferData, gl.handle, "glBufferData")
150+ purego.RegisterLibFunc(&gl.mapBuffer, gl.handle, "glMapBuffer")
151+ purego.RegisterLibFunc(&gl.unmapBuffer, gl.handle, "glUnmapBuffer")
152+
153+ // Shaders
154+ purego.RegisterLibFunc(&gl.createShader, gl.handle, "glCreateShader")
155+ purego.RegisterLibFunc(&gl.shaderSource, gl.handle, "glShaderSource")
156+ purego.RegisterLibFunc(&gl.compileShader, gl.handle, "glCompileShader")
157+ purego.RegisterLibFunc(&gl.deleteShader, gl.handle, "glDeleteShader")
158+ purego.RegisterLibFunc(&gl.createProgram, gl.handle, "glCreateProgram")
159+ purego.RegisterLibFunc(&gl.attachShader, gl.handle, "glAttachShader")
160+ purego.RegisterLibFunc(&gl.linkProgram, gl.handle, "glLinkProgram")
161+ purego.RegisterLibFunc(&gl.useProgram, gl.handle, "glUseProgram")
162+ purego.RegisterLibFunc(&gl.getUniformLocation, gl.handle, "glGetUniformLocation")
163+ purego.RegisterLibFunc(&gl.uniform1i, gl.handle, "glUniform1i")
164+
165+ // VAO / VBO
166+ purego.RegisterLibFunc(&gl.genVertexArrays, gl.handle, "glGenVertexArrays")
167+ purego.RegisterLibFunc(&gl.bindVertexArray, gl.handle, "glBindVertexArray")
168+ purego.RegisterLibFunc(&gl.deleteVertexArrays, gl.handle, "glDeleteVertexArrays")
169+ purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer")
170+ purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray")
171+
172+ // Pixel store
173+ purego.RegisterLibFunc(&gl.pixelStorei, gl.handle, "glPixelStorei")
174+
175+ // Draw / state
176+ purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear")
177+ purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor")
178+ purego.RegisterLibFunc(&gl.viewport, gl.handle, "glViewport")
179+ purego.RegisterLibFunc(&gl.drawArrays, gl.handle, "glDrawArrays")
180+ purego.RegisterLibFunc(&gl.enable, gl.handle, "glEnable")
181+ purego.RegisterLibFunc(&gl.disable, gl.handle, "glDisable")
182+
183+ return nil
184+}
A internal/infrastructure/cef/input_bridge.go +698 -0
@@ -0,0 +1,698 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/bnema/puregotk/v4/gio"
10+ "github.com/bnema/puregotk/v4/gtk"
11+
12+ "github.com/bnema/dumber/internal/infrastructure/webutil"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// GDK dead key range boundaries. Dead keys (0xfe50–0xfe8c) are compose
17+// modifiers (dead_grave, dead_acute, …) that should not generate CEF key
18+// events by themselves — the IMContext absorbs them and emits a commit
19+// with the composed character.
20+const (
21+ gdkDeadKeyStart = 0xfe50
22+ gdkDeadKeyEnd = 0xfe8c
23+
24+ // maxBMPCodepoint is the highest Unicode codepoint in the Basic
25+ // Multilingual Plane that fits in a single uint16 / UTF-16 code unit.
26+ maxBMPCodepoint = 0xFFFF
27+ // minPrintable is the lowest printable Unicode codepoint (space).
28+ minPrintable = 0x20
29+ // maxSingleByteKeyval is the upper bound for GDK keyvals that can be
30+ // used directly as Windows VK codes (when no explicit mapping exists).
31+ maxSingleByteKeyval = 0x100
32+)
33+
34+// inputBridge translates GDK events from GTK event controllers into CEF input
35+// events and forwards them to the CEF BrowserHost. The host field is nil until
36+// the browser is created asynchronously (OnAfterCreated), so all dispatch
37+// methods guard against a nil host.
38+//
39+// Dead key / compose support: a GtkIMContextSimple is attached to the key
40+// controller. When the user types a compose sequence (e.g. dead_acute + e),
41+// the IMContext absorbs the individual key events and emits a "commit" signal
42+// with the composed string ("é"). The bridge sends that as CHAR events to CEF.
43+// Keys not consumed by the IMContext flow through key-pressed as before.
44+type inputBridge struct {
45+ host purecef.BrowserHost
46+ scale int32
47+ ctx context.Context
48+ mu sync.Mutex
49+
50+ // Last known pointer position, used for scroll events which don't
51+ // carry their own coordinates from GDK.
52+ lastX, lastY float64
53+
54+ // GDK clipboard for paste support. CEF in OSR mode cannot access the
55+ // Wayland clipboard directly, so we read from GDK and inject via
56+ // ImeCommitText. Set once during attachTo, read-only afterwards.
57+ clipboard *gdk.Clipboard
58+
59+ // IMContext for dead key / compose sequence support. Held as a field
60+ // to prevent garbage collection while the controller is alive.
61+ imContext *gtk.IMContextSimple
62+ // Prevent GC from collecting signal callbacks.
63+ commitCb func(gtk.IMContext, string)
64+
65+ // glArea is stored so we can GrabFocus on click. Without explicit
66+ // focus grab, GTK may not fire the focus-enter signal and CEF won't
67+ // show the text caret.
68+ glArea *gtk.GLArea
69+
70+ // onMiddleClick is called when button 2 (middle) is pressed on a link.
71+ // The callback receives the hovered URI. Set by the factory.
72+ onMiddleClick func(uri string)
73+}
74+
75+// newInputBridge creates an input bridge with the given HiDPI scale factor.
76+func newInputBridge(ctx context.Context, scale int32) *inputBridge {
77+ return &inputBridge{ctx: ctx, scale: scale}
78+}
79+
80+// setHost is called once the browser is created to enable event dispatch.
81+func (ib *inputBridge) setHost(host purecef.BrowserHost) {
82+ ib.mu.Lock()
83+ defer ib.mu.Unlock()
84+ ib.host = host
85+}
86+
87+// attachTo creates GDK event controllers and connects them to the given GLArea.
88+func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
89+ ib.glArea = glArea
90+
91+ // Acquire GDK clipboard for paste support. CEF OSR can't access the
92+ // Wayland clipboard, so we bridge it via GDK → ImeCommitText.
93+ if display := gdk.DisplayGetDefault(); display != nil {
94+ ib.clipboard = display.GetClipboard()
95+ if ib.clipboard != nil {
96+ logging.FromContext(ib.ctx).Debug().Msg("cef: GDK clipboard acquired for paste bridge")
97+ } else {
98+ logging.FromContext(ib.ctx).Warn().Msg("cef: GDK display found but GetClipboard returned nil")
99+ }
100+ } else {
101+ logging.FromContext(ib.ctx).Warn().Msg("cef: no GDK display — clipboard paste will not work")
102+ }
103+
104+ // Motion (mouse move + enter/leave)
105+ motion := gtk.NewEventControllerMotion()
106+ motionCb := func(g gtk.EventControllerMotion, x, y float64) {
107+ mods := uint(g.GetCurrentEventState())
108+ ib.onMouseMove(x, y, mods, false)
109+ }
110+ motion.ConnectMotion(&motionCb)
111+
112+ leaveCb := func(g gtk.EventControllerMotion) {
113+ mods := uint(g.GetCurrentEventState())
114+ ib.onMouseMove(0, 0, mods, true)
115+ }
116+ motion.ConnectLeave(&leaveCb)
117+
118+ glArea.AddController(&motion.EventController)
119+
120+ // Click (press / release)
121+ click := gtk.NewGestureClick()
122+ click.SetButton(0) // listen to all buttons
123+
124+ pressedCb := func(g gtk.GestureClick, nPress int, x, y float64) {
125+ // Ensure the GLArea has GTK focus so CEF receives SetFocus(1)
126+ // and renders the text caret. Without this, clicking an input
127+ // field may not show the blinking cursor.
128+ if ib.glArea != nil {
129+ ib.glArea.GrabFocus()
130+ }
131+ btn := g.GetCurrentButton()
132+ mods := uint(g.GetCurrentEventState())
133+ ib.onMousePress(x, y, btn, mods, nPress)
134+ }
135+ click.ConnectPressed(&pressedCb)
136+
137+ releasedCb := func(g gtk.GestureClick, nPress int, x, y float64) {
138+ btn := g.GetCurrentButton()
139+ mods := uint(g.GetCurrentEventState())
140+ ib.onMouseRelease(x, y, btn, mods, nPress)
141+ }
142+ click.ConnectReleased(&releasedCb)
143+
144+ glArea.AddController(&click.EventController)
145+
146+ // Scroll
147+ scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue)
148+ scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool {
149+ ib.onScroll(dx, dy)
150+ return true
151+ }
152+ scroll.ConnectScroll(&scrollCb)
153+
154+ glArea.AddController(&scroll.EventController)
155+
156+ // Focus, keyboard, and IMContext.
157+ ib.attachFocusAndKeyboard(glArea)
158+}
159+
160+// attachFocusAndKeyboard wires focus, keyboard, and IMContext controllers
161+// to the GLArea. Extracted from attachTo to keep function length manageable.
162+func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
163+ // Focus — forward to CEF and IMContext.
164+ focus := gtk.NewEventControllerFocus()
165+ focusEnterCb := func(_ gtk.EventControllerFocus) {
166+ if ib.imContext != nil {
167+ ib.imContext.FocusIn()
168+ }
169+ ib.onFocusIn()
170+ }
171+ focus.ConnectEnter(&focusEnterCb)
172+ focusLeaveCb := func(_ gtk.EventControllerFocus) {
173+ if ib.imContext != nil {
174+ ib.imContext.Reset()
175+ ib.imContext.FocusOut()
176+ }
177+ ib.onFocusOut()
178+ }
179+ focus.ConnectLeave(&focusLeaveCb)
180+
181+ glArea.AddController(&focus.EventController)
182+
183+ // Keyboard + IMContext for dead key / compose support.
184+ key := gtk.NewEventControllerKey()
185+
186+ // Set up IMContext for dead key / compose support.
187+ // When set on the controller, GTK calls FilterKeypress on every key
188+ // event. If the IM absorbs the key (dead key, compose in-progress),
189+ // key-pressed is NOT emitted; the IM emits "commit" with the result.
190+ ib.imContext = gtk.NewIMContextSimple()
191+ if ib.imContext != nil {
192+ ib.commitCb = func(_ gtk.IMContext, text string) {
193+ ib.onIMCommit(text)
194+ }
195+ ib.imContext.ConnectCommit(&ib.commitCb)
196+ key.SetImContext(&ib.imContext.IMContext)
197+ ib.imContext.SetClientWidget(&glArea.Widget)
198+ }
199+
200+ keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
201+ mods := uint(state)
202+
203+ // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
204+ // Wayland/X11 clipboard, so we read from GDK and inject via
205+ // ImeCommitText instead of letting CEF handle the paste.
206+ if mods&uint(gdk.ControlMaskValue) != 0 &&
207+ (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) {
208+ logging.FromContext(ib.ctx).Debug().
209+ Uint("keyval", keyval).
210+ Uint("mods", mods).
211+ Msg("cef: clipboard paste intercepted (Ctrl+V)")
212+ ib.pasteFromClipboard()
213+ return true
214+ }
215+
216+ ib.onKeyPress(keyval, keycode, mods)
217+
218+ // Let modifier combos and function keys propagate to the window's
219+ // ShortcutController so that app shortcuts (Ctrl+L, F12, Escape, …)
220+ // still fire. Only consume plain text input keys.
221+ if mods&uint(gdk.ControlMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
222+ return false
223+ }
224+ // F-keys (F1–F12) and Escape are app shortcuts, not text input.
225+ if keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End {
226+ return false
227+ }
228+ if keyval == gdkKeyEscape {
229+ return false
230+ }
231+ return true
232+ }
233+ key.ConnectKeyPressed(&keyPressCb)
234+
235+ keyReleaseCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) {
236+ ib.onKeyRelease(keyval, keycode, uint(state))
237+ }
238+ key.ConnectKeyReleased(&keyReleaseCb)
239+
240+ glArea.AddController(&key.EventController)
241+
242+ // Make the GLArea focusable so it can receive key events.
243+ glArea.SetFocusable(true)
244+ glArea.SetCanFocus(true)
245+}
246+
247+// ---------------------------------------------------------------------------
248+// Event handlers
249+// ---------------------------------------------------------------------------
250+
251+func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
252+ ib.mu.Lock()
253+ host := ib.host
254+ if !leave {
255+ ib.lastX = x
256+ ib.lastY = y
257+ }
258+ ib.mu.Unlock()
259+ if host == nil {
260+ return
261+ }
262+
263+ evt := buildMouseEvent(x, y, mods, ib.scale)
264+ mouseLeave := int32(0)
265+ if leave {
266+ mouseLeave = 1
267+ }
268+ host.SendMouseMoveEvent(&evt, mouseLeave)
269+}
270+
271+func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) {
272+ ib.mu.Lock()
273+ host := ib.host
274+ middleCB := ib.onMiddleClick
275+ ib.mu.Unlock()
276+ if host == nil {
277+ return
278+ }
279+
280+ // Middle-click on a link opens in a new tab instead of sending to CEF.
281+ // The empty string parameter is unused — the callback closure resolves
282+ // the actual URI via wv.lastHoverURI at invocation time.
283+ if button == 2 && middleCB != nil {
284+ go middleCB("")
285+ return
286+ }
287+
288+ evt := buildMouseEvent(x, y, mods, ib.scale)
289+ btn := translateMouseButton(button)
290+ host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
291+}
292+
293+func (ib *inputBridge) onMouseRelease(x, y float64, button, mods uint, clickCount int) {
294+ ib.mu.Lock()
295+ host := ib.host
296+ ib.mu.Unlock()
297+ if host == nil {
298+ return
299+ }
300+
301+ evt := buildMouseEvent(x, y, mods, ib.scale)
302+ btn := translateMouseButton(button)
303+ host.SendMouseClickEvent(&evt, btn, 1, int32(clickCount))
304+}
305+
306+func (ib *inputBridge) onScroll(dx, dy float64) {
307+ ib.mu.Lock()
308+ host := ib.host
309+ x, y := ib.lastX, ib.lastY
310+ ib.mu.Unlock()
311+ if host == nil {
312+ return
313+ }
314+
315+ evt := buildMouseEvent(x, y, 0, ib.scale)
316+ deltaX, deltaY := translateScrollDeltas(dx, dy)
317+ host.SendMouseWheelEvent(&evt, deltaX, deltaY)
318+}
319+
320+func (ib *inputBridge) onFocusIn() {
321+ ib.mu.Lock()
322+ host := ib.host
323+ ib.mu.Unlock()
324+ if host == nil {
325+ return
326+ }
327+ host.SetFocus(1)
328+}
329+
330+func (ib *inputBridge) onFocusOut() {
331+ ib.mu.Lock()
332+ host := ib.host
333+ ib.mu.Unlock()
334+ if host == nil {
335+ return
336+ }
337+ host.SetFocus(0)
338+}
339+
340+func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
341+ ib.mu.Lock()
342+ host := ib.host
343+ ib.mu.Unlock()
344+ if host == nil {
345+ return
346+ }
347+
348+ // Dead keys should have been absorbed by the IMContext. If one leaks
349+ // through (e.g. no IMContext), drop it to avoid sending a bogus VK=0
350+ // RAWKEYDOWN that confuses CEF.
351+ if keyval >= gdkDeadKeyStart && keyval <= gdkDeadKeyEnd {
352+ return
353+ }
354+
355+ // Send RAWKEYDOWN first, then CHAR if printable.
356+ evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown)
357+ host.SendKeyEvent(&evt)
358+
359+ // Follow up with a CHAR event for printable characters.
360+ if ch := keyvalToChar(keyval); ch != 0 {
361+ charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar)
362+ // CHAR events use the character code as WindowsKeyCode (not the VK code).
363+ charEvt.WindowsKeyCode = int32(ch)
364+ charEvt.Character = ch
365+ charEvt.UnmodifiedCharacter = ch
366+ host.SendKeyEvent(&charEvt)
367+ }
368+}
369+
370+// onIMCommit is called when the IMContext finishes a compose sequence and
371+// commits text (e.g. dead_acute + e → "é"). We send a CHAR event for each
372+// rune to CEF. No RAWKEYDOWN is needed for composed text — this matches
373+// Chromium's internal IME integration behavior on Linux.
374+func (ib *inputBridge) onIMCommit(text string) {
375+ ib.mu.Lock()
376+ host := ib.host
377+ ib.mu.Unlock()
378+ if host == nil {
379+ return
380+ }
381+
382+ for _, r := range text {
383+ if r > maxBMPCodepoint {
384+ // Non-BMP character (emoji, etc.) — skip for now.
385+ // Full UTF-16 surrogate pair support can be added later.
386+ continue
387+ }
388+ ch := uint16(r)
389+ evt := purecef.NewKeyEvent()
390+ evt.Type = purecef.KeyEventTypeKeyeventChar
391+ evt.WindowsKeyCode = int32(ch)
392+ evt.Character = ch
393+ evt.UnmodifiedCharacter = ch
394+ host.SendKeyEvent(&evt)
395+ }
396+}
397+
398+func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) {
399+ ib.mu.Lock()
400+ host := ib.host
401+ ib.mu.Unlock()
402+ if host == nil {
403+ return
404+ }
405+
406+ evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventKeyup)
407+ host.SendKeyEvent(&evt)
408+}
409+
410+// pasteFromClipboard reads text from the GDK clipboard asynchronously and
411+// injects it into the focused CEF input via document.execCommand('insertText').
412+// ImeCommitText only works during active IME compositions, so we use JS
413+// injection which works for any focused editable element.
414+func (ib *inputBridge) pasteFromClipboard() {
415+ log := logging.FromContext(ib.ctx)
416+
417+ cb := ib.clipboard
418+ if cb == nil {
419+ log.Warn().Msg("cef: paste aborted — no GDK clipboard reference")
420+ return
421+ }
422+ ib.mu.Lock()
423+ host := ib.host
424+ ib.mu.Unlock()
425+ if host == nil {
426+ log.Warn().Msg("cef: paste aborted — no browser host")
427+ return
428+ }
429+
430+ log.Debug().Msg("cef: calling ReadTextAsync on GDK clipboard")
431+
432+ asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) {
433+ log.Debug().Uint64("result_ptr", uint64(resultPtr)).Msg("cef: ReadTextAsync callback fired")
434+
435+ result := &gio.AsyncResultBase{Ptr: resultPtr}
436+ text, err := cb.ReadTextFinish(result)
437+ if err != nil {
438+ log.Warn().Err(err).Msg("cef: ReadTextFinish failed")
439+ return
440+ }
441+ if text == "" {
442+ log.Debug().Msg("cef: clipboard text is empty, nothing to paste")
443+ return
444+ }
445+
446+ log.Debug().Int("text_len", len(text)).Msg("cef: injecting clipboard text via JS execCommand")
447+
448+ ib.mu.Lock()
449+ h := ib.host
450+ ib.mu.Unlock()
451+ if h == nil {
452+ log.Warn().Msg("cef: paste — host became nil before JS injection")
453+ return
454+ }
455+
456+ browser := h.GetBrowser()
457+ if browser == nil {
458+ log.Warn().Msg("cef: paste — GetBrowser returned nil")
459+ return
460+ }
461+ frame := browser.GetMainFrame()
462+ if frame == nil {
463+ log.Warn().Msg("cef: paste — GetMainFrame returned nil")
464+ return
465+ }
466+
467+ // Escape the text for embedding in a JS string literal.
468+ escaped := webutil.EscapeForJSString(text)
469+ js := "document.execCommand('insertText',false,'" + escaped + "')"
470+ frame.ExecuteJavaScript(js, "", 0)
471+ log.Debug().Msg("cef: paste JS executed")
472+ })
473+
474+ cb.ReadTextAsync(nil, &asyncCb, 0)
475+ log.Debug().Msg("cef: ReadTextAsync dispatched")
476+}
477+
478+// ---------------------------------------------------------------------------
479+// Translation helpers
480+// ---------------------------------------------------------------------------
481+
482+func buildMouseEvent(x, y float64, gdkMods uint, scale int32) purecef.MouseEvent {
483+ return purecef.MouseEvent{
484+ X: int32(x * float64(scale)),
485+ Y: int32(y * float64(scale)),
486+ Modifiers: translateModifiers(gdkMods),
487+ }
488+}
489+
490+func buildKeyEvent(keyval, keycode, gdkMods uint, eventType purecef.KeyEventType) purecef.KeyEvent {
491+ evt := purecef.NewKeyEvent()
492+ evt.Type = eventType
493+ evt.WindowsKeyCode = gdkKeyvalToWindowsVK(keyval)
494+ evt.NativeKeyCode = int32(keycode)
495+ evt.Modifiers = translateModifiers(gdkMods)
496+ return evt
497+}
498+
499+// translateModifiers maps GDK modifier masks to CEF EventFlags values.
500+func translateModifiers(gdkState uint) uint32 {
501+ var flags uint32
502+
503+ if gdkState&uint(gdk.ShiftMaskValue) != 0 {
504+ flags |= uint32(purecef.EventFlagsEventflagShiftDown)
505+ }
506+ if gdkState&uint(gdk.ControlMaskValue) != 0 {
507+ flags |= uint32(purecef.EventFlagsEventflagControlDown)
508+ }
509+ if gdkState&uint(gdk.AltMaskValue) != 0 {
510+ flags |= uint32(purecef.EventFlagsEventflagAltDown)
511+ }
512+ if gdkState&uint(gdk.Button1MaskValue) != 0 {
513+ flags |= uint32(purecef.EventFlagsEventflagLeftMouseButton)
514+ }
515+ if gdkState&uint(gdk.Button2MaskValue) != 0 {
516+ flags |= uint32(purecef.EventFlagsEventflagMiddleMouseButton)
517+ }
518+ if gdkState&uint(gdk.Button3MaskValue) != 0 {
519+ flags |= uint32(purecef.EventFlagsEventflagRightMouseButton)
520+ }
521+
522+ return flags
523+}
524+
525+// translateMouseButton maps a GDK button number to a CEF MouseButtonType.
526+func translateMouseButton(gdkButton uint) purecef.MouseButtonType {
527+ switch gdkButton {
528+ case 1:
529+ return purecef.MouseButtonTypeMbtLeft
530+ case 2:
531+ return purecef.MouseButtonTypeMbtMiddle
532+ case 3:
533+ return purecef.MouseButtonTypeMbtRight
534+ default:
535+ return purecef.MouseButtonTypeMbtLeft
536+ }
537+}
538+
539+// cefScrollUnitsPerNotch is the number of scroll units CEF expects per mouse wheel notch.
540+const cefScrollUnitsPerNotch = 120
541+
542+// translateScrollDeltas converts GDK scroll deltas to CEF units.
543+func translateScrollDeltas(dx, dy float64) (int32, int32) {
544+ return int32(dx * cefScrollUnitsPerNotch), int32(-dy * cefScrollUnitsPerNotch) // CEF: positive Y = scroll up
545+}
546+
547+// GDK keyval constants for special keys.
548+const (
549+ gdkKeyReturn = 0xff0d
550+ gdkKeyTab = 0xff09
551+ gdkKeyBackSpace = 0xff08
552+ gdkKeyEscape = 0xff1b
553+ gdkKeyDelete = 0xffff
554+ gdkKeySpace = 0x020
555+ gdkKeyHome = 0xff50
556+ gdkKeyEnd = 0xff57
557+ gdkKeyPageUp = 0xff55
558+ gdkKeyPageDown = 0xff56
559+
560+ // Clipboard shortcut keyvals.
561+ gdkKeyLowercaseV = 0x076
562+ gdkKeyUppercaseV = 0x056
563+)
564+
565+// keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
566+// Returns 0 for non-printable keys (arrows, F-keys, modifiers, dead keys, …).
567+// Uses gdk.KeyvalToUnicode for full Unicode coverage (accented Latin, Cyrillic, …).
568+func keyvalToChar(keyval uint) uint16 {
569+ switch keyval {
570+ case gdkKeyReturn:
571+ return '\r'
572+ case gdkKeyTab:
573+ return '\t'
574+ case gdkKeyBackSpace:
575+ return '\b'
576+ }
577+
578+ cp := gdk.KeyvalToUnicode(keyval)
579+ if cp == 0 || cp > maxBMPCodepoint {
580+ return 0
581+ }
582+ // Filter control characters (except the three handled above).
583+ if cp < minPrintable {
584+ return 0
585+ }
586+ return uint16(cp)
587+}
588+
589+// gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code.
590+func gdkKeyvalToWindowsVK(keyval uint) int32 {
591+ if vk, ok := gdkKeyvalToVKRange(keyval); ok {
592+ return vk
593+ }
594+ return gdkKeyvalToVKLookup(keyval)
595+}
596+
597+// GDK keyval range boundaries for contiguous mappings.
598+const (
599+ gdkKeyLowercaseAStart = 0x061
600+ gdkKeyLowercaseAEnd = 0x07a
601+ gdkKeyUppercaseAStart = 0x041
602+ gdkKeyUppercaseAEnd = 0x05a
603+ gdkKeyDigit0Start = 0x030
604+ gdkKeyDigit9End = 0x039
605+ gdkKeyF1Start = 0xffbe
606+ gdkKeyF12End = 0xffc9
607+ gdkKeyArrowStart = 0xff51
608+ gdkKeyArrowEnd = 0xff54
609+
610+ vkA = 0x41
611+ vkF1 = 0x70
612+ vkArrowLeft = 0x25
613+)
614+
615+// gdkKeyvalToVKRange handles contiguous GDK keyval ranges that map linearly
616+// to Windows virtual-key codes (letters, digits, F-keys, arrows).
617+func gdkKeyvalToVKRange(keyval uint) (int32, bool) {
618+ switch {
619+ // Lowercase letters -> uppercase VK codes (A-Z = 0x41-0x5A)
620+ case keyval >= gdkKeyLowercaseAStart && keyval <= gdkKeyLowercaseAEnd:
621+ return int32(keyval-gdkKeyLowercaseAStart) + vkA, true
622+ // Uppercase letters
623+ case keyval >= gdkKeyUppercaseAStart && keyval <= gdkKeyUppercaseAEnd:
624+ return int32(keyval-gdkKeyUppercaseAStart) + vkA, true
625+ // Digits 0-9 = 0x30-0x39
626+ case keyval >= gdkKeyDigit0Start && keyval <= gdkKeyDigit9End:
627+ return int32(keyval), true
628+ // F1-F12: GDK 0xffbe-0xffc9 -> VK 0x70-0x7B
629+ case keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End:
630+ return int32(keyval-gdkKeyF1Start) + vkF1, true
631+ // Arrow keys: GDK 0xff51-0xff54 -> VK Left=0x25..Down=0x28
632+ case keyval >= gdkKeyArrowStart && keyval <= gdkKeyArrowEnd:
633+ return int32(keyval-gdkKeyArrowStart) + vkArrowLeft, true
634+ default:
635+ return 0, false
636+ }
637+}
638+
639+// gdkKeyvalToVKMap maps individual GDK keyvals to Windows virtual-key codes.
640+//
641+// Punctuation ASCII values collide with Windows VK codes for navigation keys
642+// (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END) so they MUST be mapped
643+// explicitly. Shifted digit symbols (!, @, #, …) are mapped to their base
644+// digit VK code to avoid triggering navigation actions.
645+var gdkKeyvalToVKMap = map[uint]int32{
646+ // Navigation / editing
647+ gdkKeyReturn: 0x0D, // VK_RETURN
648+ gdkKeyEscape: 0x1B, // VK_ESCAPE
649+ gdkKeyTab: 0x09, // VK_TAB
650+ gdkKeyBackSpace: 0x08, // VK_BACK
651+ gdkKeyDelete: 0x2E, // VK_DELETE
652+ gdkKeySpace: 0x20, // VK_SPACE
653+ gdkKeyHome: 0x24, // VK_HOME
654+ gdkKeyEnd: 0x23, // VK_END
655+ gdkKeyPageUp: 0x21, // VK_PRIOR
656+ gdkKeyPageDown: 0x22, // VK_NEXT
657+ 0xff63: 0x2D, // Insert → VK_INSERT
658+
659+ // Modifiers
660+ 0xffe1: 0xA0, // Shift_L → VK_LSHIFT
661+ 0xffe2: 0xA1, // Shift_R → VK_RSHIFT
662+ 0xffe3: 0xA2, // Control_L → VK_LCONTROL
663+ 0xffe4: 0xA3, // Control_R → VK_RCONTROL
664+ 0xffe9: 0xA4, // Alt_L → VK_LMENU
665+ 0xffea: 0xA5, // Alt_R → VK_RMENU
666+
667+ // OEM punctuation — unshifted + shifted share the same VK
668+ '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD
669+ ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA
670+ '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS
671+ '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS
672+ ';': 0xBA, ':': 0xBA, // VK_OEM_1
673+ '/': 0xBF, '?': 0xBF, // VK_OEM_2
674+ '`': 0xC0, '~': 0xC0, // VK_OEM_3
675+ '[': 0xDB, '{': 0xDB, // VK_OEM_4
676+ '\\': 0xDC, '|': 0xDC, // VK_OEM_5
677+ ']': 0xDD, '}': 0xDD, // VK_OEM_6
678+ '\'': 0xDE, '"': 0xDE, // VK_OEM_7
679+
680+ // Shifted digit symbols (Shift+1→'!' etc.)
681+ '!': 0x31, '@': 0x32, '#': 0x33, '$': 0x34, '%': 0x35,
682+ '^': 0x36, '&': 0x37, '*': 0x38, '(': 0x39, ')': 0x30,
683+}
684+
685+// gdkKeyvalToVKLookup handles individual GDK keyvals that don't fall into
686+// contiguous ranges (letters, digits, F-keys, arrows).
687+func gdkKeyvalToVKLookup(keyval uint) int32 {
688+ if vk, ok := gdkKeyvalToVKMap[keyval]; ok {
689+ return vk
690+ }
691+ // For unmapped keys, return the keyval if it fits in a reasonable range.
692+ // This covers misc keys whose ASCII value doesn't collide with
693+ // dangerous VK codes (e.g. space=0x20=VK_SPACE — correct match).
694+ if keyval < maxSingleByteKeyval {
695+ return int32(keyval)
696+ }
697+ return 0
698+}
A internal/infrastructure/cef/internal_urls.go +110 -0
@@ -0,0 +1,110 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "path"
6+ "strings"
7+)
8+
9+const (
10+ conceptualInternalSchemePrefix = "dumb://"
11+ actualInternalScheme = "https"
12+ actualInternalHost = "dumber.invalid"
13+ actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+)
15+
16+func isConceptualInternalURL(raw string) bool {
17+ return strings.HasPrefix(raw, conceptualInternalSchemePrefix) || strings.HasPrefix(raw, "dumb:")
18+}
19+
20+func isActualInternalURL(raw string) bool {
21+ parsed, err := url.Parse(raw)
22+ if err != nil {
23+ return false
24+ }
25+ return strings.EqualFold(parsed.Scheme, actualInternalScheme) && strings.EqualFold(parsed.Host, actualInternalHost)
26+}
27+
28+func toActualInternalURL(raw string) string {
29+ if raw == "" || isActualInternalURL(raw) {
30+ return raw
31+ }
32+
33+ parsed, err := url.Parse(raw)
34+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
35+ return raw
36+ }
37+
38+ page := parsed.Host
39+ if page == "" {
40+ page = parsed.Opaque
41+ }
42+ if page == "" {
43+ return raw
44+ }
45+
46+ actualPath := "/" + page
47+ trimmedPath := strings.Trim(parsed.Path, "/")
48+ switch {
49+ case trimmedPath == "":
50+ // Keep page roots at /home instead of /home/ so relative assets resolve
51+ // from the origin root.
52+ case strings.HasPrefix(trimmedPath, "api/"):
53+ actualPath = "/" + trimmedPath
54+ case path.Ext(trimmedPath) != "":
55+ actualPath = "/" + trimmedPath
56+ default:
57+ actualPath = "/" + page + "/" + trimmedPath
58+ }
59+
60+ actual := url.URL{
61+ Scheme: actualInternalScheme,
62+ Host: actualInternalHost,
63+ Path: actualPath,
64+ RawQuery: parsed.RawQuery,
65+ Fragment: parsed.Fragment,
66+ }
67+ return actual.String()
68+}
69+
70+func toConceptualInternalURL(raw string) string {
71+ if raw == "" || !isActualInternalURL(raw) {
72+ return raw
73+ }
74+
75+ parsed, err := url.Parse(raw)
76+ if err != nil {
77+ return raw
78+ }
79+
80+ trimmedPath := strings.Trim(parsed.Path, "/")
81+ if trimmedPath == "" {
82+ return raw
83+ }
84+
85+ parts := strings.SplitN(trimmedPath, "/", 2)
86+ page := parts[0]
87+ if !isInternalPageHost(page) {
88+ return raw
89+ }
90+
91+ conceptual := url.URL{
92+ Scheme: "dumb",
93+ Host: page,
94+ RawQuery: parsed.RawQuery,
95+ Fragment: parsed.Fragment,
96+ }
97+ if len(parts) == 2 && parts[1] != "" {
98+ conceptual.Path = "/" + parts[1]
99+ }
100+ return conceptual.String()
101+}
102+
103+func isInternalPageHost(host string) bool {
104+ switch host {
105+ case homePath, configPath, webrtcPath, errorPath:
106+ return true
107+ default:
108+ return false
109+ }
110+}
A internal/infrastructure/cef/internal_urls_test.go +83 -0
@@ -0,0 +1,83 @@
1+package cef
2+
3+import "testing"
4+
5+func TestToActualInternalURL(t *testing.T) {
6+ t.Parallel()
7+
8+ tests := []struct {
9+ name string
10+ in string
11+ want string
12+ }{
13+ {
14+ name: "page root",
15+ in: "dumb://home",
16+ want: "https://dumber.invalid/home",
17+ },
18+ {
19+ name: "api path stays at origin root",
20+ in: "dumb://home/api/message",
21+ want: "https://dumber.invalid/api/message",
22+ },
23+ {
24+ name: "root asset stays at origin root",
25+ in: "dumb://home/favicon.ico",
26+ want: "https://dumber.invalid/favicon.ico",
27+ },
28+ {
29+ name: "page subroute stays under page namespace",
30+ in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
31+ want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
32+ },
33+ {
34+ name: "actual internal URL unchanged",
35+ in: "https://dumber.invalid/config",
36+ want: "https://dumber.invalid/config",
37+ },
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ t.Parallel()
43+ if got := toActualInternalURL(tt.in); got != tt.want {
44+ t.Fatalf("toActualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
45+ }
46+ })
47+ }
48+}
49+
50+func TestToConceptualInternalURL(t *testing.T) {
51+ t.Parallel()
52+
53+ tests := []struct {
54+ name string
55+ in string
56+ want string
57+ }{
58+ {
59+ name: "page root",
60+ in: "https://dumber.invalid/home",
61+ want: "dumb://home",
62+ },
63+ {
64+ name: "page subroute",
65+ in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
66+ want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
67+ },
68+ {
69+ name: "root asset is left unchanged",
70+ in: "https://dumber.invalid/favicon.ico",
71+ want: "https://dumber.invalid/favicon.ico",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ t.Parallel()
78+ if got := toConceptualInternalURL(tt.in); got != tt.want {
79+ t.Fatalf("toConceptualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
80+ }
81+ })
82+ }
83+}
A internal/infrastructure/cef/messaging.go +190 -0
@@ -0,0 +1,190 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Compile-time check: MessageRouter implements port.WebUIHandlerRouter.
15+var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
16+
17+// Message is the JS -> Go message envelope sent via fetch to /api/message.
18+type Message = port.WebUIMessage
19+
20+type handlerEntry struct {
21+ handler portHandlerAdapter
22+ callback string
23+ errorCallback string
24+ world string
25+}
26+
27+// MessageRouter dispatches fetch-based message events to registered handlers.
28+type MessageRouter struct {
29+ handlers map[string]handlerEntry
30+ baseCtx context.Context
31+ mu sync.RWMutex
32+}
33+
34+// NewMessageRouter creates a new message router.
35+func NewMessageRouter(ctx context.Context) *MessageRouter {
36+ if ctx == nil {
37+ ctx = context.Background()
38+ }
39+ return &MessageRouter{
40+ handlers: make(map[string]handlerEntry),
41+ baseCtx: ctx,
42+ }
43+}
44+
45+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
46+ if r == nil {
47+ return
48+ }
49+ if ctx == nil {
50+ ctx = context.Background()
51+ }
52+ r.mu.Lock()
53+ r.baseCtx = ctx
54+ r.mu.Unlock()
55+}
56+
57+// RegisterHandler registers a handler for a message type.
58+// Satisfies port.WebUIHandlerRouter.
59+func (r *MessageRouter) RegisterHandler(msgType string, handler port.WebUIMessageHandler) error {
60+ if msgType == "" {
61+ return errors.New("message type cannot be empty")
62+ }
63+ if handler == nil {
64+ return errors.New("message handler cannot be nil")
65+ }
66+ r.mu.Lock()
67+ defer r.mu.Unlock()
68+ r.handlers[msgType] = handlerEntry{handler: portHandlerAdapter{handler}}
69+ return nil
70+}
71+
72+// RegisterHandlerWithCallbacks registers a handler with response callback names.
73+// callback is invoked on success, errorCallback (optional) on failure.
74+// worldName allows targeting a specific script world (unused in CEF fetch bridge).
75+// Satisfies port.WebUIHandlerRouter.
76+func (r *MessageRouter) RegisterHandlerWithCallbacks(
77+ msgType, callback, errorCallback, worldName string, handler port.WebUIMessageHandler,
78+) error {
79+ if msgType == "" {
80+ return errors.New("message type cannot be empty")
81+ }
82+ if handler == nil {
83+ return errors.New("message handler cannot be nil")
84+ }
85+ if callback == "" {
86+ return errors.New("callback cannot be empty when registering with callbacks")
87+ }
88+ r.mu.Lock()
89+ defer r.mu.Unlock()
90+ r.handlers[msgType] = handlerEntry{
91+ handler: portHandlerAdapter{handler},
92+ callback: callback,
93+ errorCallback: errorCallback,
94+ world: worldName,
95+ }
96+ return nil
97+}
98+
99+// portHandlerAdapter wraps a port.WebUIMessageHandler, converting the WebViewID type.
100+type portHandlerAdapter struct {
101+ inner port.WebUIMessageHandler
102+}
103+
104+func (a portHandlerAdapter) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
105+ return a.inner.Handle(ctx, port.WebViewID(webviewID), payload)
106+}
107+
108+// HandleMessage decodes a raw JSON message body and routes it to the correct handler.
109+// Returns the JSON-encoded response. Called by the scheme handler for /api/message POST.
110+func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, body []byte) ([]byte, error) {
111+ r.mu.RLock()
112+ baseCtx := r.baseCtx
113+ r.mu.RUnlock()
114+ log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
115+
116+ var msg Message
117+ if err := json.Unmarshal(body, &msg); err != nil {
118+ return nil, fmt.Errorf("unmarshal message: %w", err)
119+ }
120+ if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
121+ msg.WebViewID = msg.WebViewIDAlt
122+ }
123+ if msg.WebViewID == 0 {
124+ msg.WebViewID = webviewID
125+ }
126+ if msg.Type == "" {
127+ return nil, errors.New("message missing type")
128+ }
129+
130+ r.mu.RLock()
131+ entry, ok := r.handlers[msg.Type]
132+ r.mu.RUnlock()
133+
134+ if !ok || entry.handler.inner == nil {
135+ log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
136+ return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
137+ }
138+
139+ log.Debug().
140+ Str("type", msg.Type).
141+ Uint64("webview_id", msg.WebViewID).
142+ Int("payload_len", len(msg.Payload)).
143+ Msg("dispatching message to handler")
144+
145+ if ctx == nil {
146+ ctx = baseCtx
147+ }
148+ resp, err := entry.handler.Handle(ctx, msg.WebViewID, msg.Payload)
149+ if err != nil {
150+ log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
151+ result := map[string]any{"error": err.Error()}
152+ if entry.errorCallback != "" {
153+ result["_callback"] = entry.errorCallback
154+ }
155+ return json.Marshal(result)
156+ }
157+
158+ result := map[string]any{"data": resp}
159+ if entry.callback != "" {
160+ result["_callback"] = entry.callback
161+ }
162+ return json.Marshal(result)
163+}
164+
165+// MessageBridgeJS is the JavaScript shim injected into dumb:// pages to provide
166+// the window.dumber.postMessage() bridge via fetch.
167+//
168+// The message body is sent via the X-Dumber-Body header (base64-encoded) rather
169+// than the fetch body, because CEF's PostData element API (GetElements/GetBytes)
170+// requires unexported wrappers in purego-cef. Using a header avoids that limitation
171+// while keeping the bridge simple and reliable.
172+const MessageBridgeJS = `
173+window.dumber = {
174+ postMessage: async function(msg) {
175+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
176+ const r = await fetch('/api/message', {
177+ method: 'POST',
178+ headers: {
179+ 'Content-Type': 'application/json',
180+ 'X-Dumber-Body': encoded
181+ }
182+ });
183+ const resp = await r.json();
184+ if (resp && resp._callback && window[resp._callback]) {
185+ window[resp._callback](resp.data);
186+ }
187+ return resp;
188+ }
189+};
190+`
A internal/infrastructure/cef/pool.go +128 -0
@@ -0,0 +1,128 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Compile-time interface check.
14+var _ port.WebViewPool = (*WebViewPool)(nil)
15+
16+// errPoolClosed is returned when an operation is attempted on a closed pool.
17+var errPoolClosed = errors.New("cef: webview pool is closed")
18+
19+// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
20+// In Phase 1 the pool is simple: Acquire pops or creates, Release destroys.
21+type WebViewPool struct {
22+ factory *WebViewFactory
23+ mu sync.Mutex
24+ pool []*WebView
25+ closed bool
26+}
27+
28+// newWebViewPool returns a pool backed by the given factory.
29+func newWebViewPool(factory *WebViewFactory) *WebViewPool {
30+ return &WebViewPool{
31+ factory: factory,
32+ }
33+}
34+
35+// Acquire obtains a WebView from the pool. If a pre-warmed WebView is
36+// available it is returned immediately; otherwise a new one is created.
37+func (p *WebViewPool) Acquire(ctx context.Context) (port.WebView, error) {
38+ p.mu.Lock()
39+ if p.closed {
40+ p.mu.Unlock()
41+ return nil, errPoolClosed
42+ }
43+
44+ if len(p.pool) > 0 {
45+ wv := p.pool[len(p.pool)-1]
46+ p.pool = p.pool[:len(p.pool)-1]
47+ p.mu.Unlock()
48+ wv.generation.Add(1)
49+ return wv, nil
50+ }
51+ p.mu.Unlock()
52+
53+ return p.factory.Create(ctx)
54+}
55+
56+// Release destroys the WebView. In Phase 1 there is no reuse — the browser
57+// is closed and all resources are freed immediately.
58+func (p *WebViewPool) Release(wv port.WebView) {
59+ if wv == nil {
60+ return
61+ }
62+ wv.Destroy()
63+}
64+
65+// Prewarm creates count WebViews synchronously and adds them to the pool.
66+func (p *WebViewPool) Prewarm(count int) {
67+ if count <= 0 {
68+ return
69+ }
70+
71+ ctx := p.factory.engine.ctx
72+ log := logging.FromContext(ctx)
73+ views := make([]*WebView, 0, count)
74+ for range count {
75+ wv, err := p.factory.Create(ctx)
76+ if err != nil {
77+ log.Warn().Err(err).Msg("cef: pool prewarm failed to create WebView")
78+ continue
79+ }
80+ cefWV, ok := wv.(*WebView)
81+ if !ok {
82+ log.Warn().Str("concrete_type", fmt.Sprintf("%T", wv)).Msg("cef: pool prewarm got unexpected WebView type")
83+ wv.Destroy()
84+ continue
85+ }
86+ views = append(views, cefWV)
87+ }
88+
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ if p.closed {
92+ for _, wv := range views {
93+ wv.Destroy()
94+ }
95+ return
96+ }
97+ p.pool = append(p.pool, views...)
98+}
99+
100+// PrewarmAsync schedules WebView creation. In Phase 1 this calls Prewarm
101+// synchronously; a future phase will use the GTK idle loop to avoid blocking.
102+// TODO(phase2): schedule creation via glib.IdleAdd for non-blocking startup.
103+func (p *WebViewPool) PrewarmAsync(_ context.Context, count int) {
104+ p.Prewarm(count)
105+}
106+
107+func (p *WebViewPool) Size() int {
108+ p.mu.Lock()
109+ defer p.mu.Unlock()
110+ return len(p.pool)
111+}
112+
113+// Close shuts down the pool and destroys all pooled WebViews.
114+func (p *WebViewPool) Close() {
115+ p.mu.Lock()
116+ if p.closed {
117+ p.mu.Unlock()
118+ return
119+ }
120+ p.closed = true
121+ views := p.pool
122+ p.pool = nil
123+ p.mu.Unlock()
124+
125+ for _, wv := range views {
126+ wv.Destroy()
127+ }
128+}
A internal/infrastructure/cef/render_pipeline.go +1232 -0
@@ -0,0 +1,1232 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+ "time"
8+ "unsafe"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/puregotk/v4/gdk"
12+ "github.com/bnema/puregotk/v4/gtk"
13+)
14+
15+// rect is a simple rectangle for dirty rect tracking.
16+type rect struct {
17+ X, Y, Width, Height int32
18+}
19+
20+// renderPipeline implements PBO double-buffered OpenGL rendering of CEF's
21+// off-screen BGRA pixel buffers into a GtkGLArea widget.
22+//
23+// CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint
24+// copies dirty regions into a CPU-side staging buffer on the CEF UI thread,
25+// and GTK later uploads from staging → PBO → texture before drawing a
26+// fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
27+type renderPipeline struct {
28+ ctx context.Context
29+ gl *glLoader
30+ glArea *gtk.GLArea
31+
32+ // GL resources (created on first render).
33+ texture uint32
34+ popupTexture uint32
35+ pbo [2]uint32
36+ pboIndex int
37+ program uint32
38+ vao uint32
39+ vbo uint32
40+
41+ // Surface dimensions (in device pixels, i.e. scaled).
42+ // Atomics allow lock-free reads from CEF callbacks (viewRectSize).
43+ widthAtomic atomic.Int32
44+ heightAtomic atomic.Int32
45+ scale int32
46+
47+ // Staging: handlePaint copies dirty rects here, render signal uploads.
48+ mu sync.Mutex
49+ staging []byte
50+ dirtyRects []rect
51+ needsUpload bool
52+ sizeChanged bool
53+ forceFullUpload bool
54+
55+ // Popup staging for native CEF widgets like <select> menus.
56+ popupVisible bool
57+ popupRect rect
58+ popupStaging []byte
59+ popupWidth int32
60+ popupHeight int32
61+ popupNeedsUpload bool
62+ popupSizeChanged bool
63+
64+ // GL initialized flag.
65+ glReady bool
66+ glWidth int32
67+ glHeight int32
68+
69+ // Diagnostics can be updated from both the CEF UI thread and GTK thread.
70+ diagMu sync.Mutex
71+ viewRectSeq atomic.Uint64
72+ screenInfoSeq atomic.Uint64
73+ paintSeq atomic.Uint64
74+ lastQueuedPaintSeq atomic.Uint64
75+ glRenderSeq atomic.Uint64
76+ paintCount uint64
77+ acceleratedPaintCount uint64
78+ queueRenderCount uint64
79+ glRenderCount uint64
80+ uploadCount uint64
81+ fullUploadCount uint64
82+ paintBytes uint64
83+ uploadBytes uint64
84+ dirtyRectCount uint64
85+ paintCopyTotalNs uint64
86+ uploadTotalNs uint64
87+ renderTotalNs uint64
88+ maxPaintCopyNs int64
89+ maxUploadNs int64
90+ maxRenderNs int64
91+ diagLastLogAt time.Time
92+ diagLastPaintCount uint64
93+ diagLastAccelCount uint64
94+ diagLastQueueCount uint64
95+ diagLastRenderCount uint64
96+ diagLastUploadCount uint64
97+ diagLastFullUploads uint64
98+ diagLastPaintBytes uint64
99+ diagLastUploadBytes uint64
100+ diagLastPaintCopyNs uint64
101+ diagLastUploadNs uint64
102+ diagLastRenderNs uint64
103+
104+ // onFirstResize is called once when the first non-zero resize occurs.
105+ onFirstResize func(width, height int32)
106+
107+ // onResizeCB is called on every non-zero resize after the first.
108+ onResizeCB func(width, height int32)
109+}
110+
111+// Shader sources for the fullscreen textured quad.
112+const vertexShaderSource = "" +
113+ "#version 330 core\n" +
114+ "layout(location=0) in vec2 aPos;\n" +
115+ "layout(location=1) in vec2 aUV;\n" +
116+ "out vec2 vUV;\n" +
117+ "void main() {\n" +
118+ " gl_Position = vec4(aPos, 0.0, 1.0);\n" +
119+ " vUV = aUV;\n" +
120+ "}\x00"
121+
122+const fragmentShaderSource = "" +
123+ "#version 330 core\n" +
124+ "in vec2 vUV;\n" +
125+ "out vec4 fragColor;\n" +
126+ "uniform sampler2D tex;\n" +
127+ "void main() {\n" +
128+ " fragColor = texture(tex, vUV);\n" +
129+ "}\x00"
130+
131+// Fullscreen quad: position (x,y) + UV (u,v), triangle strip.
132+// UV Y is flipped because CEF's buffer origin is top-left, GL is bottom-left.
133+var quadVertices = [16]float32{
134+ -1, -1, 0, 1, // bottom-left
135+ 1, -1, 1, 1, // bottom-right
136+ -1, 1, 0, 0, // top-left
137+ 1, 1, 1, 0, // top-right
138+}
139+
140+// newRenderPipeline creates a GtkGLArea and wires up the render and resize
141+// signals. The returned pipeline is ready to receive handlePaint calls.
142+func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPipeline {
143+ if scale < 1 {
144+ scale = 1
145+ }
146+
147+ rp := &renderPipeline{
148+ ctx: ctx,
149+ gl: gl,
150+ scale: scale,
151+ }
152+
153+ rp.glArea = gtk.NewGLArea()
154+ rp.glArea.SetRequiredVersion(3, 3)
155+ rp.glArea.SetAutoRender(false)
156+ rp.glArea.SetHasDepthBuffer(false)
157+ rp.glArea.SetHasStencilBuffer(false)
158+
159+ // Wire signals. puregotk takes *func(...) for signal callbacks.
160+ renderCb := func(_ gtk.GLArea, _ *gdk.GLContext) bool {
161+ return rp.onGLRender()
162+ }
163+ rp.glArea.ConnectRender(&renderCb)
164+
165+ resizeCb := func(_ gtk.GLArea, w int, h int) {
166+ rp.onResize(int32(w), int32(h))
167+ }
168+ rp.glArea.ConnectResize(&resizeCb)
169+
170+ return rp
171+}
172+
173+// handlePaint is called from CEF's OnPaint callback. It copies dirty rect
174+// regions from the transient CEF buffer into the persistent staging buffer and
175+// marks the next GTK render for upload.
176+func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
177+ if len(buffer) == 0 || width <= 0 || height <= 0 {
178+ return
179+ }
180+
181+ startedAt := time.Now()
182+ rp.lastQueuedPaintSeq.Store(paintSeq)
183+ if rp.ctx != nil {
184+ logging.FromContext(rp.ctx).Trace().
185+ Uint64("paint_seq", paintSeq).
186+ Int32("width", width).
187+ Int32("height", height).
188+ Int("rect_count", len(rects)).
189+ Int("buffer_len", len(buffer)).
190+ Msg("cef: handlePaint begin")
191+ }
192+ rp.mu.Lock()
193+
194+ bufSize, ok := bgraBufferSize(width, height)
195+ if !ok {
196+ rp.mu.Unlock()
197+ return
198+ }
199+ copiedBytes := uint64(0)
200+ sizeChanged := false
201+
202+ // Detect size change, or first paint (staging not yet allocated).
203+ if stagingNeedsReset(rp.staging, rp.widthAtomic.Load(), rp.heightAtomic.Load(), width, height, bufSize) {
204+ rp.widthAtomic.Store(width)
205+ rp.heightAtomic.Store(height)
206+ rp.staging = make([]byte, bufSize)
207+ rp.sizeChanged = true
208+ rp.forceFullUpload = true
209+ sizeChanged = true
210+ // On size change, copy the entire buffer.
211+ copiedBytes = uint64(copy(rp.staging, buffer))
212+ } else {
213+ // Copy only dirty rect rows.
214+ var truncated bool
215+ copiedBytes, rects, truncated = copyDirtyRectsIntoStaging(rp.staging, buffer, width, height, rects)
216+ if truncated && rp.ctx != nil {
217+ logging.FromContext(rp.ctx).Warn().
218+ Uint64("paint_seq", paintSeq).
219+ Int32("width", width).
220+ Int32("height", height).
221+ Int("buffer_len", len(buffer)).
222+ Int("expected_len", bufSize).
223+ Msg("cef: truncated paint buffer while copying dirty rects")
224+ }
225+ }
226+
227+ // Accumulate dirty rects across multiple OnPaint calls between renders.
228+ // If we replaced instead of appending, paints that arrive before the
229+ // next GL render would lose their dirty rect metadata — the pixels are
230+ // in staging but never uploaded to the texture, causing trails/ghosts.
231+ rp.dirtyRects = append(rp.dirtyRects, rects...)
232+ rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load())
233+ rp.needsUpload = true
234+
235+ rp.mu.Unlock()
236+
237+ copyDuration := time.Since(startedAt)
238+ rp.diagMu.Lock()
239+ rp.paintCount++
240+ rp.paintBytes += copiedBytes
241+ rp.dirtyRectCount += uint64(len(rects))
242+ rp.paintCopyTotalNs += uint64(copyDuration)
243+ if copyDuration.Nanoseconds() > rp.maxPaintCopyNs {
244+ rp.maxPaintCopyNs = copyDuration.Nanoseconds()
245+ }
246+ rp.diagMu.Unlock()
247+
248+ if rp.ctx != nil {
249+ if sizeChanged && copiedBytes < uint64(bufSize) {
250+ logging.FromContext(rp.ctx).Warn().
251+ Uint64("paint_seq", paintSeq).
252+ Int32("width", width).
253+ Int32("height", height).
254+ Int("buffer_len", len(buffer)).
255+ Int("expected_len", bufSize).
256+ Msg("cef: truncated full paint buffer on resize")
257+ }
258+ logging.FromContext(rp.ctx).Trace().
259+ Uint64("paint_seq", paintSeq).
260+ Uint64("copied_bytes", copiedBytes).
261+ Bool("size_changed", sizeChanged).
262+ Dur("copy_duration", copyDuration).
263+ Msg("cef: handlePaint end")
264+ }
265+ rp.maybeLogDiagnostics()
266+}
267+
268+func (rp *renderPipeline) queuePaintRender() {
269+ rp.diagMu.Lock()
270+ rp.queueRenderCount++
271+ rp.diagMu.Unlock()
272+ rp.glArea.QueueRender()
273+}
274+
275+// onGLRender is the GTK "render" signal handler. GL context is current.
276+func (rp *renderPipeline) onGLRender() bool {
277+ renderSeq := rp.glRenderSeq.Add(1)
278+ paintSeq := rp.lastQueuedPaintSeq.Load()
279+ renderStartedAt := time.Now()
280+ if rp.ctx != nil {
281+ logging.FromContext(rp.ctx).Trace().
282+ Uint64("render_seq", renderSeq).
283+ Uint64("paint_seq", paintSeq).
284+ Msg("cef: onGLRender begin")
285+ }
286+ rp.mu.Lock()
287+
288+ needsUpload := rp.needsUpload
289+ sizeChanged := rp.sizeChanged
290+ uploadWidth := rp.widthAtomic.Load()
291+ uploadHeight := rp.heightAtomic.Load()
292+
293+ rp.mu.Unlock()
294+
295+ if !rp.glReady || sizeChanged {
296+ rp.initGL(uploadWidth, uploadHeight)
297+ }
298+
299+ if !rp.glReady {
300+ rp.recordGLRenderDuration(renderStartedAt)
301+ return true
302+ }
303+
304+ if needsUpload {
305+ uploadStartedAt := time.Now()
306+ uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq)
307+ if didUpload {
308+ rp.recordGLUploadDuration(uploadStartedAt, uploadedBytes, fullUpload)
309+ }
310+ }
311+
312+ // Draw the main view texture.
313+ gl := rp.gl
314+ w, h, _ := rp.viewRectSize()
315+ gl.viewport(0, 0, w, h)
316+ gl.clearColor(0, 0, 0, 1)
317+ gl.clear(glColorBufferBit)
318+ rp.drawTexture(rp.texture)
319+
320+ if rp.uploadPopupTexture(renderSeq, paintSeq) {
321+ rp.drawPopupOverlay(w, h)
322+ }
323+
324+ renderDuration := time.Since(renderStartedAt)
325+ rp.recordGLRenderDuration(renderStartedAt)
326+ rp.maybeLogDiagnostics()
327+ if rp.ctx != nil {
328+ logging.FromContext(rp.ctx).Trace().
329+ Uint64("render_seq", renderSeq).
330+ Uint64("paint_seq", paintSeq).
331+ Dur("render_duration", renderDuration).
332+ Bool("needs_upload", needsUpload).
333+ Bool("size_changed", sizeChanged).
334+ Msg("cef: onGLRender end")
335+ }
336+
337+ return true
338+}
339+
340+func (rp *renderPipeline) recordGLRenderDuration(startedAt time.Time) {
341+ renderDuration := time.Since(startedAt)
342+ rp.diagMu.Lock()
343+ rp.glRenderCount++
344+ rp.renderTotalNs += uint64(renderDuration)
345+ if renderDuration.Nanoseconds() > rp.maxRenderNs {
346+ rp.maxRenderNs = renderDuration.Nanoseconds()
347+ }
348+ rp.diagMu.Unlock()
349+ rp.maybeLogDiagnostics()
350+}
351+
352+func (rp *renderPipeline) recordGLUploadDuration(startedAt time.Time, uploadedBytes uint64, fullUpload bool) {
353+ uploadDuration := time.Since(startedAt)
354+ rp.diagMu.Lock()
355+ rp.uploadCount++
356+ if fullUpload {
357+ rp.fullUploadCount++
358+ }
359+ rp.uploadBytes += uploadedBytes
360+ rp.uploadTotalNs += uint64(uploadDuration)
361+ if uploadDuration.Nanoseconds() > rp.maxUploadNs {
362+ rp.maxUploadNs = uploadDuration.Nanoseconds()
363+ }
364+ rp.diagMu.Unlock()
365+}
366+
367+func (rp *renderPipeline) drawTexture(texture uint32) {
368+ if texture == 0 {
369+ return
370+ }
371+ gl := rp.gl
372+ gl.useProgram(rp.program)
373+ gl.bindTexture(glTexture2D, texture)
374+ gl.bindVertexArray(rp.vao)
375+ gl.drawArrays(glTriangleStrip, 0, 4)
376+ gl.bindVertexArray(0)
377+ gl.bindTexture(glTexture2D, 0)
378+ gl.useProgram(0)
379+}
380+
381+// uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
382+// transfers to the GL texture via async DMA. The mutex serializes CEF-thread
383+// staging writes against GTK-thread PBO copies so the upload sees a consistent
384+// staging buffer and dirty-rect set.
385+func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, bool) {
386+ gl := rp.gl
387+
388+ rp.mu.Lock()
389+ w := rp.glWidth
390+ h := rp.glHeight
391+ needsUpload := rp.needsUpload
392+ rp.mu.Unlock()
393+ if !needsUpload || w <= 0 || h <= 0 {
394+ return 0, false, false
395+ }
396+
397+ if rp.ctx != nil {
398+ logging.FromContext(rp.ctx).Trace().
399+ Uint64("render_seq", renderSeq).
400+ Uint64("paint_seq", paintSeq).
401+ Int32("width", w).
402+ Int32("height", h).
403+ Msg("cef: uploadToPBO begin")
404+ }
405+
406+ bufSize := int64(w) * int64(h) * 4
407+ stride := int(w) * 4
408+ uploadedBytes, fullUpload, dirtyRectCount, ok := rp.uploadMappedPBO(gl, bufSize, stride, w, h, renderSeq, paintSeq)
409+ if !ok {
410+ return 0, fullUpload, false
411+ }
412+
413+ // Swap PBO index for next frame.
414+ rp.pboIndex = 1 - rp.pboIndex
415+
416+ gl.bindBuffer(glPixelUnpackBuffer, 0)
417+ if rp.ctx != nil {
418+ logging.FromContext(rp.ctx).Trace().
419+ Uint64("render_seq", renderSeq).
420+ Uint64("paint_seq", paintSeq).
421+ Uint64("uploaded_bytes", uploadedBytes).
422+ Int("dirty_rect_count", dirtyRectCount).
423+ Bool("full_upload", fullUpload).
424+ Int("next_pbo_index", rp.pboIndex).
425+ Msg("cef: uploadToPBO end")
426+ }
427+ return uploadedBytes, fullUpload, true
428+}
429+
430+func (rp *renderPipeline) uploadMappedPBO(
431+ gl *glLoader, bufSize int64, stride int, w, h int32, renderSeq, paintSeq uint64,
432+) (uint64, bool, int, bool) {
433+ gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex])
434+ gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw)
435+ mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)
436+ if mapped == nil {
437+ gl.bindBuffer(glPixelUnpackBuffer, 0)
438+ return 0, false, 0, false
439+ }
440+
441+ mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize))
442+ defer gl.bindBuffer(glPixelUnpackBuffer, 0)
443+
444+ rp.mu.Lock()
445+ if !rp.needsUpload {
446+ rp.mu.Unlock()
447+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
448+ return 0, false, 0, false
449+ }
450+ return 0, false, 0, false
451+ }
452+
453+ if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight {
454+ rp.mu.Unlock()
455+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
456+ return 0, false, 0, false
457+ }
458+ if rp.ctx != nil {
459+ logging.FromContext(rp.ctx).Trace().
460+ Uint64("render_seq", renderSeq).
461+ Uint64("paint_seq", paintSeq).
462+ Msg("cef: skipping upload while newer surface resize is pending")
463+ }
464+ return 0, false, 0, false
465+ }
466+
467+ fullUpload := rp.forceFullUpload
468+ dirtyRectCount, dirtyRects := len(rp.dirtyRects), make([]rect, 0, len(rp.dirtyRects))
469+ if fullUpload {
470+ copied := copy(mappedSlice, rp.staging)
471+ if copied < len(mappedSlice) {
472+ clear(mappedSlice[copied:])
473+ }
474+ } else {
475+ dirtyRects = append(dirtyRects, rp.dirtyRects...)
476+ }
477+ rp.needsUpload, rp.forceFullUpload = false, false
478+ rp.dirtyRects = rp.dirtyRects[:0]
479+ rp.mu.Unlock()
480+
481+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
482+ if rp.ctx != nil {
483+ logging.FromContext(rp.ctx).Error().
484+ Int32("width", w).
485+ Int32("height", h).
486+ Bool("full_upload", fullUpload).
487+ Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
488+ }
489+ return 0, fullUpload, dirtyRectCount, false
490+ }
491+
492+ gl.bindTexture(glTexture2D, rp.texture)
493+ if fullUpload {
494+ gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil)
495+ gl.bindTexture(glTexture2D, 0)
496+ return uint64(len(mappedSlice)), true, dirtyRectCount, true
497+ }
498+
499+ gl.pixelStorei(glUnpackRowLength, w)
500+ uploadedBytes := copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects)
501+ for _, rawRect := range dirtyRects {
502+ r, ok := clampDirtyRect(rawRect, w, h)
503+ if !ok {
504+ continue
505+ }
506+ offset := uintptr(int(r.Y)*stride + int(r.X)*4)
507+ gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
508+ glBGRA, glUnsignedByte, pboOffset(offset))
509+ }
510+ gl.pixelStorei(glUnpackRowLength, 0)
511+ gl.bindTexture(glTexture2D, 0)
512+ return uploadedBytes, false, dirtyRectCount, true
513+}
514+
515+func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
516+ // Copy staging data under lock, then release to avoid holding the mutex
517+ // during GL upload calls which may block on GPU synchronization.
518+ rp.mu.Lock()
519+
520+ if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
521+ rp.mu.Unlock()
522+ return false
523+ }
524+
525+ if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
526+ rp.mu.Unlock()
527+ return true
528+ }
529+
530+ // Snapshot the staging data and dimensions while holding the lock.
531+ pixelsCopy := make([]byte, len(rp.popupStaging))
532+ copy(pixelsCopy, rp.popupStaging)
533+ popupW := rp.popupWidth
534+ popupH := rp.popupHeight
535+ sizeChanged := rp.popupSizeChanged
536+ needsCreate := rp.popupTexture == 0
537+
538+ rp.mu.Unlock()
539+
540+ // Perform GL operations without the lock.
541+ gl := rp.gl
542+
543+ if needsCreate {
544+ var tex uint32
545+ gl.genTextures(1, &tex)
546+ gl.bindTexture(glTexture2D, tex)
547+ gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
548+ gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
549+ gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
550+ gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
551+
552+ // Store the texture ID under lock.
553+ rp.mu.Lock()
554+ rp.popupTexture = tex
555+ rp.mu.Unlock()
556+ } else {
557+ rp.mu.Lock()
558+ tex := rp.popupTexture
559+ rp.mu.Unlock()
560+ gl.bindTexture(glTexture2D, tex)
561+ }
562+
563+ pixels := unsafe.Pointer(&pixelsCopy[0])
564+ if sizeChanged {
565+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels)
566+ } else {
567+ gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels)
568+ }
569+ gl.bindTexture(glTexture2D, 0)
570+
571+ // Update state under lock.
572+ rp.mu.Lock()
573+ rp.popupNeedsUpload = false
574+ rp.popupSizeChanged = false
575+ rp.mu.Unlock()
576+
577+ if rp.ctx != nil {
578+ logging.FromContext(rp.ctx).Debug().
579+ Uint64("render_seq", renderSeq).
580+ Uint64("paint_seq", paintSeq).
581+ Int32("popup_width", popupW).
582+ Int32("popup_height", popupH).
583+ Msg("cef: popup texture uploaded")
584+ }
585+
586+ return true
587+}
588+
589+func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
590+ rp.mu.Lock()
591+ popupVisible := rp.popupVisible
592+ popupRect := rp.popupRect
593+ popupWidth := rp.popupWidth
594+ popupHeight := rp.popupHeight
595+ texture := rp.popupTexture
596+ rp.mu.Unlock()
597+
598+ if !popupVisible || texture == 0 || popupWidth <= 0 || popupHeight <= 0 || viewWidth <= 0 || viewHeight <= 0 {
599+ return
600+ }
601+
602+ popupRect.Width = popupWidth
603+ popupRect.Height = popupHeight
604+ popupRect, ok := clampDirtyRect(popupRect, viewWidth, viewHeight)
605+ if !ok {
606+ return
607+ }
608+
609+ viewportY := viewHeight - popupRect.Y - popupRect.Height
610+ if viewportY < 0 {
611+ viewportY = 0
612+ }
613+
614+ rp.gl.viewport(popupRect.X, viewportY, popupRect.Width, popupRect.Height)
615+ rp.drawTexture(texture)
616+ rp.gl.viewport(0, 0, viewWidth, viewHeight)
617+}
618+
619+// initGL creates or recreates all GL resources for the provided surface size.
620+func (rp *renderPipeline) initGL(width, height int32) {
621+ if width <= 0 || height <= 0 {
622+ return
623+ }
624+
625+ gl := rp.gl
626+
627+ // Clean up previous resources if reinitializing.
628+ if rp.glReady {
629+ rp.deleteGLResources()
630+ }
631+
632+ rp.mu.Lock()
633+ if rp.widthAtomic.Load() == width && rp.heightAtomic.Load() == height {
634+ rp.sizeChanged = false
635+ }
636+ rp.forceFullUpload = true
637+ rp.glWidth = width
638+ rp.glHeight = height
639+ rp.mu.Unlock()
640+
641+ // Texture.
642+ gl.genTextures(1, &rp.texture)
643+ gl.bindTexture(glTexture2D, rp.texture)
644+ gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
645+ gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
646+ gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
647+ gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
648+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), width, height, 0, glBGRA, glUnsignedByte, nil)
649+ gl.bindTexture(glTexture2D, 0)
650+
651+ // PBOs.
652+ bufSize := int64(width) * int64(height) * 4
653+ gl.genBuffers(1, &rp.pbo[0])
654+ gl.genBuffers(1, &rp.pbo[1])
655+ for i := 0; i < 2; i++ {
656+ gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[i])
657+ gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw)
658+ }
659+ gl.bindBuffer(glPixelUnpackBuffer, 0)
660+ rp.pboIndex = 0
661+
662+ // Shaders.
663+ rp.program = rp.buildShaderProgram()
664+
665+ // VAO + VBO.
666+ gl.genVertexArrays(1, &rp.vao)
667+ gl.bindVertexArray(rp.vao)
668+
669+ gl.genBuffers(1, &rp.vbo)
670+ gl.bindBuffer(glArrayBuffer, rp.vbo)
671+ gl.bufferData(glArrayBuffer, int64(len(quadVertices)*4), unsafe.Pointer(&quadVertices[0]), glStaticDraw)
672+
673+ // Position attribute: location 0, 2 floats, stride 16, offset 0.
674+ gl.vertexAttribPointer(0, 2, glFloat, false, 4*4, 0)
675+ gl.enableVertexAttribArray(0)
676+
677+ // UV attribute: location 1, 2 floats, stride 16, offset 8.
678+ gl.vertexAttribPointer(1, 2, glFloat, false, 4*4, 2*4)
679+ gl.enableVertexAttribArray(1)
680+
681+ gl.bindBuffer(glArrayBuffer, 0)
682+ gl.bindVertexArray(0)
683+
684+ // Set the "tex" uniform to texture unit 0.
685+ gl.useProgram(rp.program)
686+ texName := []byte("tex\x00")
687+ loc := gl.getUniformLocation(rp.program, &texName[0])
688+ gl.uniform1i(loc, 0)
689+ gl.useProgram(0)
690+
691+ rp.glReady = true
692+}
693+
694+func (rp *renderPipeline) recordAcceleratedPaint() uint64 {
695+ rp.diagMu.Lock()
696+ rp.acceleratedPaintCount++
697+ count := rp.acceleratedPaintCount
698+ rp.diagMu.Unlock()
699+ rp.maybeLogDiagnostics()
700+ return count
701+}
702+
703+func (rp *renderPipeline) maybeLogDiagnostics() {
704+ rp.diagMu.Lock()
705+ defer rp.diagMu.Unlock()
706+
707+ now := time.Now()
708+ if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second {
709+ return
710+ }
711+
712+ paintDelta := rp.paintCount - rp.diagLastPaintCount
713+ accelDelta := rp.acceleratedPaintCount - rp.diagLastAccelCount
714+ queueDelta := rp.queueRenderCount - rp.diagLastQueueCount
715+ renderDelta := rp.glRenderCount - rp.diagLastRenderCount
716+ uploadDelta := rp.uploadCount - rp.diagLastUploadCount
717+ fullUploadDelta := rp.fullUploadCount - rp.diagLastFullUploads
718+ paintBytesDelta := rp.paintBytes - rp.diagLastPaintBytes
719+ uploadBytesDelta := rp.uploadBytes - rp.diagLastUploadBytes
720+ paintCopyNsDelta := rp.paintCopyTotalNs - rp.diagLastPaintCopyNs
721+ uploadNsDelta := rp.uploadTotalNs - rp.diagLastUploadNs
722+ renderNsDelta := rp.renderTotalNs - rp.diagLastRenderNs
723+ if paintDelta == 0 && accelDelta == 0 && queueDelta == 0 && renderDelta == 0 && uploadDelta == 0 {
724+ return
725+ }
726+
727+ elapsed := now.Sub(rp.diagLastLogAt)
728+ if rp.diagLastLogAt.IsZero() || elapsed <= 0 {
729+ elapsed = time.Second
730+ }
731+ elapsedSec := elapsed.Seconds()
732+
733+ paintAvgUs := float64(0)
734+ if paintDelta > 0 {
735+ paintAvgUs = float64(paintCopyNsDelta) / float64(paintDelta) / 1_000
736+ }
737+ uploadAvgUs := float64(0)
738+ if uploadDelta > 0 {
739+ uploadAvgUs = float64(uploadNsDelta) / float64(uploadDelta) / 1_000
740+ }
741+ renderAvgUs := float64(0)
742+ if renderDelta > 0 {
743+ renderAvgUs = float64(renderNsDelta) / float64(renderDelta) / 1_000
744+ }
745+
746+ logging.FromContext(rp.ctx).Debug().
747+ Float64("paint_hz", float64(paintDelta)/elapsedSec).
748+ Float64("queue_hz", float64(queueDelta)/elapsedSec).
749+ Float64("render_hz", float64(renderDelta)/elapsedSec).
750+ Float64("upload_hz", float64(uploadDelta)/elapsedSec).
751+ Uint64("paint_delta", paintDelta).
752+ Uint64("accelerated_paint_delta", accelDelta).
753+ Uint64("queue_delta", queueDelta).
754+ Uint64("render_delta", renderDelta).
755+ Uint64("upload_delta", uploadDelta).
756+ Uint64("full_upload_delta", fullUploadDelta).
757+ Float64("paint_mb", float64(paintBytesDelta)/(1024*1024)).
758+ Float64("upload_mb", float64(uploadBytesDelta)/(1024*1024)).
759+ Float64("avg_paint_copy_us", paintAvgUs).
760+ Float64("avg_upload_us", uploadAvgUs).
761+ Float64("avg_render_us", renderAvgUs).
762+ Float64("max_paint_copy_us", float64(rp.maxPaintCopyNs)/1_000).
763+ Float64("max_upload_us", float64(rp.maxUploadNs)/1_000).
764+ Float64("max_render_us", float64(rp.maxRenderNs)/1_000).
765+ Uint64("paint_total", rp.paintCount).
766+ Uint64("accelerated_paint_total", rp.acceleratedPaintCount).
767+ Uint64("render_total", rp.glRenderCount).
768+ Uint64("upload_total", rp.uploadCount).
769+ Msg("cef: render pipeline activity")
770+
771+ rp.diagLastLogAt = now
772+ rp.diagLastPaintCount = rp.paintCount
773+ rp.diagLastAccelCount = rp.acceleratedPaintCount
774+ rp.diagLastQueueCount = rp.queueRenderCount
775+ rp.diagLastRenderCount = rp.glRenderCount
776+ rp.diagLastUploadCount = rp.uploadCount
777+ rp.diagLastFullUploads = rp.fullUploadCount
778+ rp.diagLastPaintBytes = rp.paintBytes
779+ rp.diagLastUploadBytes = rp.uploadBytes
780+ rp.diagLastPaintCopyNs = rp.paintCopyTotalNs
781+ rp.diagLastUploadNs = rp.uploadTotalNs
782+ rp.diagLastRenderNs = rp.renderTotalNs
783+}
784+
785+// buildShaderProgram compiles and links the vertex+fragment shaders.
786+func (rp *renderPipeline) buildShaderProgram() uint32 {
787+ gl := rp.gl
788+
789+ vs := compileShader(gl, glVertexShader, vertexShaderSource)
790+ fs := compileShader(gl, glFragmentShader, fragmentShaderSource)
791+
792+ prog := gl.createProgram()
793+ gl.attachShader(prog, vs)
794+ gl.attachShader(prog, fs)
795+ gl.linkProgram(prog)
796+
797+ // Shaders can be deleted after linking.
798+ gl.deleteShader(vs)
799+ gl.deleteShader(fs)
800+
801+ return prog
802+}
803+
804+// compileShader compiles a single GLSL shader and returns its GL handle.
805+// TODO: add glGetShaderiv + glGetShaderInfoLog to gl_loader to check compilation status.
806+func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
807+ shader := gl.createShader(shaderType)
808+
809+ // source already has null terminator.
810+ srcBytes := []byte(source)
811+ srcPtr := &srcBytes[0]
812+ srcLen := int32(len(source) - 1) // exclude null terminator from length
813+ gl.shaderSource(shader, 1, &srcPtr, &srcLen)
814+ gl.compileShader(shader)
815+
816+ return shader
817+}
818+
819+// onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels;
820+// we multiply by scale to get device pixels.
821+func (rp *renderPipeline) onResize(width, height int32) {
822+ rp.mu.Lock()
823+
824+ scaled := func(v int32) int32 {
825+ return v * rp.scale
826+ }
827+
828+ w, h := scaled(width), scaled(height)
829+ rp.widthAtomic.Store(w)
830+ rp.heightAtomic.Store(h)
831+ rp.sizeChanged = true
832+
833+ firstCB := rp.onFirstResize
834+ if firstCB != nil {
835+ rp.onFirstResize = nil
836+ }
837+ resizeCB := rp.onResizeCB
838+ rp.mu.Unlock()
839+
840+ if firstCB != nil {
841+ firstCB(w, h)
842+ } else if resizeCB != nil {
843+ resizeCB(w, h)
844+ }
845+}
846+
847+func (rp *renderPipeline) setPopupVisible(show bool) {
848+ rp.mu.Lock()
849+ rp.popupVisible = show
850+ if !show {
851+ rp.popupStaging = nil
852+ rp.popupWidth = 0
853+ rp.popupHeight = 0
854+ rp.popupNeedsUpload = false
855+ rp.popupSizeChanged = false
856+ }
857+ rp.mu.Unlock()
858+ rp.glArea.QueueRender()
859+}
860+
861+func (rp *renderPipeline) setPopupRect(popup rect) {
862+ rp.mu.Lock()
863+ rp.popupRect = rect{
864+ X: popup.X * rp.scale,
865+ Y: popup.Y * rp.scale,
866+ Width: popup.Width * rp.scale,
867+ Height: popup.Height * rp.scale,
868+ }
869+ rp.mu.Unlock()
870+ rp.glArea.QueueRender()
871+}
872+
873+func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, paintSeq uint64) {
874+ if len(buffer) == 0 || width <= 0 || height <= 0 {
875+ return
876+ }
877+
878+ bufSize, ok := bgraBufferSize(width, height)
879+ if !ok {
880+ return
881+ }
882+
883+ rp.mu.Lock()
884+ if len(rp.popupStaging) != bufSize {
885+ rp.popupStaging = make([]byte, bufSize)
886+ rp.popupSizeChanged = true
887+ } else if rp.popupWidth != width || rp.popupHeight != height {
888+ rp.popupSizeChanged = true
889+ }
890+ copied := copy(rp.popupStaging, buffer)
891+ if copied < len(rp.popupStaging) {
892+ clear(rp.popupStaging[copied:])
893+ }
894+ rp.popupWidth = width
895+ rp.popupHeight = height
896+ rp.popupNeedsUpload = true
897+ rp.popupVisible = true
898+ rp.mu.Unlock()
899+
900+ if rp.ctx != nil {
901+ logging.FromContext(rp.ctx).Debug().
902+ Uint64("paint_seq", paintSeq).
903+ Int32("popup_width", width).
904+ Int32("popup_height", height).
905+ Msg("cef: handlePopupPaint")
906+ }
907+
908+ rp.glArea.QueueRender()
909+}
910+
911+func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
912+ width = rp.widthAtomic.Load()
913+ height = rp.heightAtomic.Load()
914+ scale = rp.scale
915+ if scale <= 0 {
916+ scale = 1
917+ }
918+ return width, height, scale
919+}
920+
921+func (rp *renderPipeline) nextViewRectSeq() uint64 {
922+ return rp.viewRectSeq.Add(1)
923+}
924+
925+func (rp *renderPipeline) nextScreenInfoSeq() uint64 {
926+ return rp.screenInfoSeq.Add(1)
927+}
928+
929+func (rp *renderPipeline) nextPaintSeq() uint64 {
930+ return rp.paintSeq.Add(1)
931+}
932+
933+func clampDirtyRect(r rect, width, height int32) (rect, bool) {
934+ if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 {
935+ return rect{}, false
936+ }
937+
938+ x0 := max(r.X, 0)
939+ y0 := max(r.Y, 0)
940+ x1 := min(r.X+r.Width, width)
941+ y1 := min(r.Y+r.Height, height)
942+
943+ if x1 <= x0 || y1 <= y0 {
944+ return rect{}, false
945+ }
946+
947+ return rect{
948+ X: x0,
949+ Y: y0,
950+ Width: x1 - x0,
951+ Height: y1 - y0,
952+ }, true
953+}
954+
955+// destroy releases all GL resources.
956+func (rp *renderPipeline) destroy() {
957+ if !rp.glReady {
958+ return
959+ }
960+ rp.deleteGLResources()
961+ rp.glReady = false
962+}
963+
964+// deleteGLResources frees texture, PBOs, VAO, VBO, and program.
965+func (rp *renderPipeline) deleteGLResources() {
966+ gl := rp.gl
967+ if rp.texture != 0 {
968+ gl.deleteTextures(1, &rp.texture)
969+ rp.texture = 0
970+ }
971+ if rp.popupTexture != 0 {
972+ gl.deleteTextures(1, &rp.popupTexture)
973+ rp.popupTexture = 0
974+ }
975+ for i := 0; i < 2; i++ {
976+ if rp.pbo[i] != 0 {
977+ gl.deleteBuffers(1, &rp.pbo[i])
978+ rp.pbo[i] = 0
979+ }
980+ }
981+ if rp.vao != 0 {
982+ gl.deleteVertexArrays(1, &rp.vao)
983+ rp.vao = 0
984+ }
985+ if rp.vbo != 0 {
986+ gl.deleteBuffers(1, &rp.vbo)
987+ rp.vbo = 0
988+ }
989+ if rp.program != 0 {
990+ // deleteProgram not in glLoader — just zero it. The program leaks on
991+ // reinit but that only happens on resize which is rare. We can add
992+ // glDeleteProgram to glLoader if needed.
993+ rp.program = 0
994+ }
995+ rp.glWidth = 0
996+ rp.glHeight = 0
997+}
998+
999+func bgraBufferSize(width, height int32) (int, bool) {
1000+ if width <= 0 || height <= 0 {
1001+ return 0, false
1002+ }
1003+ size64 := int64(width) * int64(height) * 4
1004+ if size64 <= 0 {
1005+ return 0, false
1006+ }
1007+ return int(size64), true
1008+}
1009+
1010+// coalesceDirtyRects merges overlapping or edge-adjacent dirty rects to reduce
1011+// redundant row-by-row PBO copies. If the total dirty area exceeds 60% of the
1012+// surface, it collapses to a single full-surface rect.
1013+//
1014+// This is called inside handlePaint's mu.Lock() section so it must be fast for
1015+// small n. O(n^2) is fine since n is typically < 20.
1016+func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect {
1017+ if len(rects) <= 1 {
1018+ return rects
1019+ }
1020+
1021+ // Check if total dirty area exceeds 60% of the surface — if so,
1022+ // a single full-surface upload is cheaper than per-rect copies.
1023+ totalArea := int64(0)
1024+ surfaceArea := int64(surfaceW) * int64(surfaceH)
1025+ for _, r := range rects {
1026+ totalArea += int64(r.Width) * int64(r.Height)
1027+ }
1028+ if surfaceArea > 0 && totalArea*100 > surfaceArea*60 {
1029+ return []rect{{0, 0, surfaceW, surfaceH}}
1030+ }
1031+
1032+ // Merge overlapping or edge-adjacent rects. Two rects touch if their
1033+ // X ranges overlap-or-touch AND Y ranges overlap-or-touch. When merged,
1034+ // both are replaced with their bounding box. Repeat until stable.
1035+ merged := make([]rect, len(rects))
1036+ copy(merged, rects)
1037+
1038+ changed := true
1039+ for changed {
1040+ changed = false
1041+ for i := 0; i < len(merged); i++ {
1042+ for j := i + 1; j < len(merged); {
1043+ if !rectsTouch(merged[i], merged[j]) {
1044+ j++
1045+ continue
1046+ }
1047+ merged[i] = boundingBox(merged[i], merged[j])
1048+ // Remove j by swapping with last element.
1049+ merged[j] = merged[len(merged)-1]
1050+ merged = merged[:len(merged)-1]
1051+ changed = true
1052+ }
1053+ }
1054+ }
1055+
1056+ return merged
1057+}
1058+
1059+// rectsTouch returns true if two rects overlap or share an edge (adjacent).
1060+func rectsTouch(a, b rect) bool {
1061+ // a's right edge <= b's left edge → no horizontal contact
1062+ if a.X+a.Width < b.X || b.X+b.Width < a.X {
1063+ return false
1064+ }
1065+ // a's bottom edge <= b's top edge → no vertical contact
1066+ if a.Y+a.Height < b.Y || b.Y+b.Height < a.Y {
1067+ return false
1068+ }
1069+ return true
1070+}
1071+
1072+// boundingBox returns the smallest rect containing both a and b.
1073+func boundingBox(a, b rect) rect {
1074+ x := a.X
1075+ if b.X < x {
1076+ x = b.X
1077+ }
1078+ y := a.Y
1079+ if b.Y < y {
1080+ y = b.Y
1081+ }
1082+ right := a.X + a.Width
1083+ if br := b.X + b.Width; br > right {
1084+ right = br
1085+ }
1086+ bottom := a.Y + a.Height
1087+ if bb := b.Y + b.Height; bb > bottom {
1088+ bottom = bb
1089+ }
1090+ return rect{X: x, Y: y, Width: right - x, Height: bottom - y}
1091+}
1092+
1093+func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
1094+ return staging == nil ||
1095+ len(staging) != expectedBytes ||
1096+ currentWidth != nextWidth ||
1097+ currentHeight != nextHeight
1098+}
1099+
1100+func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rect) (uint64, []rect, bool) {
1101+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1102+ return 0, nil, false
1103+ }
1104+
1105+ stride := int(width) * 4
1106+ copiedBytes := uint64(0)
1107+ truncated := false
1108+ sanitized := make([]rect, 0, len(rects))
1109+
1110+ for _, rawRect := range rects {
1111+ r, ok := clampDirtyRect(rawRect, width, height)
1112+ if !ok {
1113+ continue
1114+ }
1115+ sanitized = append(sanitized, r)
1116+
1117+ for row := r.Y; row < r.Y+r.Height; row++ {
1118+ dstOff := int(row)*stride + int(r.X)*4
1119+ if dstOff < 0 || dstOff >= len(dst) {
1120+ truncated = true
1121+ break
1122+ }
1123+
1124+ rowBytes := int(r.Width) * 4
1125+ dstEnd := dstOff + rowBytes
1126+ if dstEnd > len(dst) {
1127+ dstEnd = len(dst)
1128+ truncated = true
1129+ }
1130+
1131+ srcOff := int(row)*stride + int(r.X)*4
1132+ if srcOff < 0 || srcOff >= len(src) {
1133+ clear(dst[dstOff:dstEnd])
1134+ truncated = true
1135+ continue
1136+ }
1137+
1138+ srcEnd := srcOff + rowBytes
1139+ if srcEnd > len(src) {
1140+ srcEnd = len(src)
1141+ truncated = true
1142+ }
1143+
1144+ copyLen := srcEnd - srcOff
1145+ maxDstLen := dstEnd - dstOff
1146+ if copyLen > maxDstLen {
1147+ copyLen = maxDstLen
1148+ truncated = true
1149+ }
1150+
1151+ if copyLen > 0 {
1152+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1153+ copiedBytes += uint64(copyLen)
1154+ }
1155+
1156+ if dstOff+copyLen < dstEnd {
1157+ clear(dst[dstOff+copyLen : dstEnd])
1158+ }
1159+ }
1160+ }
1161+
1162+ return copiedBytes, sanitized, truncated
1163+}
1164+
1165+// copyDirtyRectsRaw copies only the rows covered by dirty rects from src into
1166+// dst at matching offsets. Both src and dst must be BGRA buffers of dimensions
1167+// width x height. Unlike copyDirtyRectsIntoStaging, the destination buffer's
1168+// existing data outside dirty rect rows is left untouched — the caller must
1169+// ensure that only the copied rows will be read downstream.
1170+//
1171+// On a full-surface dirty rect (e.g. resize), this degrades to copying all
1172+// rows — equivalent to a full buffer copy.
1173+//
1174+// Returns the total bytes copied.
1175+func copyDirtyRectsRaw(dst, src []byte, width, height int32, rects []rect) uint64 {
1176+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1177+ return 0
1178+ }
1179+
1180+ stride := int(width) * 4
1181+ copiedBytes := uint64(0)
1182+
1183+ for _, rawRect := range rects {
1184+ r, ok := clampDirtyRect(rawRect, width, height)
1185+ if !ok {
1186+ continue
1187+ }
1188+
1189+ for row := r.Y; row < r.Y+r.Height; row++ {
1190+ dstOff := int(row)*stride + int(r.X)*4
1191+ if dstOff < 0 || dstOff >= len(dst) {
1192+ break
1193+ }
1194+
1195+ rowBytes := int(r.Width) * 4
1196+ dstEnd := dstOff + rowBytes
1197+ if dstEnd > len(dst) {
1198+ dstEnd = len(dst)
1199+ }
1200+
1201+ srcOff := int(row)*stride + int(r.X)*4
1202+ if srcOff < 0 || srcOff >= len(src) {
1203+ continue
1204+ }
1205+
1206+ srcEnd := srcOff + rowBytes
1207+ if srcEnd > len(src) {
1208+ srcEnd = len(src)
1209+ }
1210+
1211+ copyLen := srcEnd - srcOff
1212+ maxDstLen := dstEnd - dstOff
1213+ if copyLen > maxDstLen {
1214+ copyLen = maxDstLen
1215+ }
1216+
1217+ if copyLen > 0 {
1218+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1219+ copiedBytes += uint64(copyLen)
1220+ }
1221+ }
1222+ }
1223+
1224+ return copiedBytes
1225+}
1226+
1227+// pboOffset converts a byte offset into an unsafe.Pointer for use with
1228+// glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
1229+// byte offset into the bound buffer rather than a real memory address.
1230+func pboOffset(offset uintptr) unsafe.Pointer {
1231+ return unsafe.Add(nil, offset)
1232+}
A internal/infrastructure/cef/render_pipeline_test.go +243 -0
@@ -0,0 +1,243 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "sort"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestStagingNeedsResetWhenBufferSizeMismatches(t *testing.T) {
14+ staging := make([]byte, 8)
15+
16+ require.True(t, stagingNeedsReset(staging, 2, 2, 2, 2, 16))
17+ require.False(t, stagingNeedsReset(make([]byte, 16), 2, 2, 2, 2, 16))
18+}
19+
20+func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
21+ const (
22+ width = int32(4)
23+ height = int32(2)
24+ )
25+
26+ dst := bytes.Repeat([]byte{0xaa}, 32)
27+ src := make([]byte, 28)
28+ for i := range src {
29+ src[i] = byte(i + 1)
30+ }
31+
32+ copied, sanitized, truncated := copyDirtyRectsIntoStaging(dst, src, width, height, []rect{{
33+ X: 0,
34+ Y: 0,
35+ Width: width,
36+ Height: height,
37+ }})
38+
39+ require.True(t, truncated)
40+ require.Equal(t, uint64(len(src)), copied)
41+ require.Len(t, sanitized, 1)
42+ require.Equal(t, src, dst[:len(src)])
43+ require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
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+}
A internal/infrastructure/cef/runtime_config.go +74 -0
@@ -0,0 +1,74 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+var (
11+ ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet")
12+ ErrRelatedWebViewUnsupported = errors.New("cef: related popup webviews are not supported yet")
13+ ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
14+)
15+
16+type RuntimeConfig struct {
17+ CEFDir string
18+ LogFile string
19+ LogSeverity int32
20+ WindowlessFrameRate int32
21+ EnableAudioHandler bool
22+ EnableContextMenuHandler bool
23+ TraceHandlers bool
24+}
25+
26+type TranscodingRuntimeConfig struct {
27+ Enabled bool
28+ HWAccel string
29+ MaxConcurrent int
30+ Quality string
31+}
32+
33+type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
34+
35+type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error
36+
37+type MediaClassifier struct {
38+ IsProprietaryVideoMIME func(string) bool
39+ IsOpenVideoMIME func(string) bool
40+ IsStreamingManifestMIME func(string) bool
41+ IsStreamingManifestURL func(string) bool
42+ IsEagerTranscodeURL func(string) bool
43+ ParseSyntheticTranscodeURL func(string) (string, string, string, bool)
44+}
45+
46+func (m MediaClassifier) normalize() MediaClassifier {
47+ if m.IsProprietaryVideoMIME == nil {
48+ m.IsProprietaryVideoMIME = func(string) bool { return false }
49+ }
50+ if m.IsOpenVideoMIME == nil {
51+ m.IsOpenVideoMIME = func(string) bool { return false }
52+ }
53+ if m.IsStreamingManifestMIME == nil {
54+ m.IsStreamingManifestMIME = func(string) bool { return false }
55+ }
56+ if m.IsStreamingManifestURL == nil {
57+ m.IsStreamingManifestURL = func(string) bool { return false }
58+ }
59+ if m.IsEagerTranscodeURL == nil {
60+ m.IsEagerTranscodeURL = func(string) bool { return false }
61+ }
62+ if m.ParseSyntheticTranscodeURL == nil {
63+ m.ParseSyntheticTranscodeURL = func(string) (string, string, string, bool) { return "", "", "", false }
64+ }
65+ return m
66+}
67+
68+type EngineDependencies struct {
69+ RegisterHandlers HandlerRegistrar
70+ RegisterAccentHandlers AccentHandlerRegistrar
71+ CurrentConfigPayload func() ([]byte, error)
72+ DefaultConfigPayload func() ([]byte, error)
73+ MediaClassifier MediaClassifier
74+}
A internal/infrastructure/cef/scheme_handler.go +549 -0
@@ -0,0 +1,549 @@
1+package cef
2+
3+import (
4+ "context"
5+ "embed"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "html"
10+ "io/fs"
11+ "net/http"
12+ "net/url"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "time"
17+ "unsafe"
18+
19+ purecef "github.com/bnema/purego-cef/cef"
20+
21+ "github.com/bnema/dumber/internal/application/port"
22+ "github.com/bnema/dumber/internal/infrastructure/webutil"
23+ "github.com/bnema/dumber/internal/logging"
24+ "github.com/rs/zerolog"
25+)
26+
27+// Scheme path constants matching WebKit's naming.
28+const (
29+ homePath = "home"
30+ configPath = "config"
31+ webrtcPath = "webrtc"
32+ errorPath = "error"
33+ indexHTML = "index.html"
34+ maxSchemeTruncatedURLLength = 240
35+)
36+
37+// pageRootFiles maps internal page hosts/paths to their HTML entry points.
38+var pageRootFiles = map[string]string{
39+ homePath: indexHTML,
40+ configPath: "config.html",
41+ webrtcPath: "webrtc.html",
42+ errorPath: "error.html",
43+}
44+
45+// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
46+// internal https://dumber.invalid origin used by CEF.
47+type dumbSchemeHandler struct {
48+ ctx context.Context
49+ messageRouter *MessageRouter
50+ transcoder port.MediaTranscoder
51+ assets embed.FS
52+ assetsSet bool
53+ assetDir string
54+ logger zerolog.Logger
55+ currentConfigPayload func() ([]byte, error)
56+ defaultConfigPayload func() ([]byte, error)
57+ mu sync.RWMutex
58+
59+ // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
60+ // Set by the engine to write to the GDK system clipboard.
61+ onClipboardSet func(text string)
62+}
63+
64+// newDumbSchemeHandler creates a handler for internal CEF pages.
65+func newDumbSchemeHandler(
66+ ctx context.Context,
67+ router *MessageRouter,
68+ transcoder port.MediaTranscoder,
69+ currentConfigPayload func() ([]byte, error),
70+ defaultConfigPayload func() ([]byte, error),
71+) (*dumbSchemeHandler, error) {
72+ if currentConfigPayload == nil {
73+ return nil, fmt.Errorf("current config payload builder not configured")
74+ }
75+ if defaultConfigPayload == nil {
76+ return nil, fmt.Errorf("default config payload builder not configured")
77+ }
78+
79+ log := logging.FromContext(ctx)
80+ return &dumbSchemeHandler{
81+ ctx: ctx,
82+ messageRouter: router,
83+ transcoder: transcoder,
84+ assetDir: "webui",
85+ logger: log.With().Str("component", "scheme-handler").Logger(),
86+ currentConfigPayload: currentConfigPayload,
87+ defaultConfigPayload: defaultConfigPayload,
88+ }, nil
89+}
90+
91+// setAssets sets the embedded filesystem containing webui assets.
92+func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
93+ h.mu.Lock()
94+ defer h.mu.Unlock()
95+ h.assets = assets
96+ h.assetsSet = true
97+ h.logger.Debug().Msg("assets filesystem configured")
98+}
99+
100+// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
101+// for requests to either dumb:// or the internal https origin.
102+func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
103+ reqURL := request.GetURL()
104+ method := request.GetMethod()
105+
106+ h.logger.Debug().
107+ Str("url", reqURL).
108+ Str("method", method).
109+ Msg("scheme handler create")
110+
111+ u, err := url.Parse(reqURL)
112+ if err != nil {
113+ h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
114+ return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
115+ }
116+
117+ if isCEFCrashPageURL(u) {
118+ originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
119+ return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
120+ }
121+
122+ // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a
123+ // normal HTTPS origin so Chromium applies the standard web security model.
124+ if isConceptualInternalURL(reqURL) {
125+ targetURL := toActualInternalURL(reqURL)
126+ if targetURL != "" && targetURL != reqURL {
127+ return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
128+ }
129+ }
130+
131+ // Route API requests.
132+ path := u.Path
133+
134+ if strings.HasPrefix(path, "/api/") {
135+ return h.handleAPI(method, path, request)
136+ }
137+
138+ // Serve static assets.
139+ return h.handleAsset(u)
140+}
141+
142+// handleAPI routes API requests to the message router or built-in handlers.
143+func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler {
144+ switch {
145+ case path == "/api/message" && strings.EqualFold(method, "POST"):
146+ body := readBodyFromHeader(request)
147+ if body == nil {
148+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
149+ }
150+ resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
151+ if err != nil {
152+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
153+ }
154+ return h.newRawResourceHandler(http.StatusOK, "application/json", resp)
155+
156+ case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
157+ return h.handleConfigAPI(h.currentConfigPayload)
158+
159+ case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")):
160+ return h.handleConfigAPI(h.defaultConfigPayload)
161+
162+ case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
163+ return h.handleTranscodeAPI(request)
164+
165+ case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
166+ return h.handleClipboardSet(request)
167+
168+ default:
169+ return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
170+ }
171+}
172+
173+func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
174+ if h.transcoder == nil {
175+ return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
176+ }
177+
178+ reqURL := request.GetURL()
179+ parsed, err := url.Parse(reqURL)
180+ if err != nil {
181+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
182+ }
183+
184+ sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
185+ if sourceURL == "" {
186+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
187+ }
188+
189+ sourceParsed, err := url.Parse(sourceURL)
190+ if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
191+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
192+ }
193+
194+ headers := make(map[string]string)
195+ if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
196+ headers["User-Agent"] = userAgent
197+ }
198+ if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" {
199+ headers["Referer"] = referer
200+ } else if referer := request.GetReferrerURL(); referer != "" {
201+ headers["Referer"] = referer
202+ }
203+ if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" {
204+ headers["Origin"] = origin
205+ }
206+
207+ ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute)
208+ h.logger.Info().
209+ Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)).
210+ Int("forwarded_header_count", len(headers)).
211+ Msg("scheme handler returning transcoding stream")
212+
213+ return purecef.NewResourceHandler(&transcodingResourceHandler{
214+ transcoder: h.transcoder,
215+ sourceURL: sourceURL,
216+ headers: headers,
217+ ctx: ctx,
218+ cancel: cancel,
219+ logf: func() zerolog.Logger {
220+ return h.logger.With().Str("component", "scheme-transcoding").Logger()
221+ },
222+ })
223+}
224+
225+func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
226+ if build == nil {
227+ return nil, fmt.Errorf("config payload builder not configured")
228+ }
229+ return build()
230+}
231+
232+// handleConfigAPI returns a JSON response with the given config payload builder.
233+func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
234+ data, err := resolveConfigPayload(build)
235+ if err != nil {
236+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
237+ }
238+ return h.newRawResourceHandler(http.StatusOK, "application/json", data)
239+}
240+
241+// handleClipboardSet receives copied text from JS copy/cut events and writes
242+// it to the system clipboard via the engine callback.
243+const maxClipboardBytes = 10 << 20 // 10 MB
244+
245+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
246+ h.logger.Debug().Msg("cef: /api/clipboard-set request received")
247+
248+ body := readBodyFromHeader(request)
249+ if body == nil {
250+ h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
251+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
252+ }
253+ if len(body) > maxClipboardBytes {
254+ h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
255+ return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
256+ }
257+
258+ var payload struct {
259+ Text string `json:"text"`
260+ }
261+ if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
262+ h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
263+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
264+ }
265+
266+ h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
267+
268+ if h.onClipboardSet != nil {
269+ h.onClipboardSet(payload.Text)
270+ } else {
271+ h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
272+ }
273+
274+ return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
275+}
276+
277+func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
278+ return purecef.NewResourceHandler(&staticResourceHandler{
279+ contentType: "text/plain; charset=utf-8",
280+ statusCode: status,
281+ headers: map[string]string{
282+ "Location": location,
283+ "Cache-Control": "no-store",
284+ },
285+ })
286+}
287+
288+// handleAsset serves static files from the embedded filesystem.
289+func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
290+ h.mu.RLock()
291+ hasAssets := h.assetsSet
292+ assetDir := h.assetDir
293+ h.mu.RUnlock()
294+
295+ if !hasAssets {
296+ return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
297+ }
298+
299+ relPath, ok := resolveAssetPath(u)
300+ if !ok {
301+ return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
302+ }
303+
304+ fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
305+ data, err := fs.ReadFile(h.assets, fullPath)
306+ if err != nil {
307+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
308+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
309+ }
310+
311+ contentType := getMimeType(relPath)
312+ h.logger.Debug().
313+ Str("path", fullPath).
314+ Str("content_type", contentType).
315+ Int("size", len(data)).
316+ Msg("serving asset")
317+
318+ return h.newRawResourceHandler(http.StatusOK, contentType, data)
319+}
320+
321+// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
322+// to a relative asset path inside assets/webui.
323+func resolveAssetPath(u *url.URL) (string, bool) {
324+ if u == nil {
325+ return "", false
326+ }
327+
328+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
329+ return resolveActualAssetPath(u)
330+ }
331+
332+ return resolveConceptualAssetPath(u)
333+}
334+
335+func resolveConceptualAssetPath(u *url.URL) (string, bool) {
336+ if u == nil {
337+ return "", false
338+ }
339+
340+ if root, ok := pageRootFiles[u.Host]; ok {
341+ path := strings.TrimPrefix(u.Path, "/")
342+ if path == "" {
343+ return root, true
344+ }
345+ // Don't serve API paths as assets.
346+ if strings.HasPrefix(path, "api/") {
347+ return "", false
348+ }
349+ return path, true
350+ }
351+
352+ // Handle opaque URLs like dumb:home.
353+ if root, ok := pageRootFiles[u.Opaque]; ok {
354+ return root, true
355+ }
356+ return "", false
357+}
358+
359+func resolveActualAssetPath(u *url.URL) (string, bool) {
360+ if u == nil {
361+ return "", false
362+ }
363+
364+ path := strings.Trim(u.Path, "/")
365+ if path == "" {
366+ return "", false
367+ }
368+
369+ if root, ok := pageRootFiles[path]; ok {
370+ return root, true
371+ }
372+
373+ if strings.HasPrefix(path, "api/") {
374+ return "", false
375+ }
376+
377+ parts := strings.SplitN(path, "/", 2)
378+ if len(parts) == 2 && isInternalPageHost(parts[0]) {
379+ if parts[1] == "crash" && parts[0] == homePath {
380+ return "", false
381+ }
382+ }
383+
384+ // Serve root assets like homepage.min.js, style.css and favicon.png.
385+ return path, true
386+}
387+
388+func isCEFCrashPageURL(u *url.URL) bool {
389+ if u == nil {
390+ return false
391+ }
392+
393+ switch {
394+ case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
395+ return strings.Trim(u.Path, "/") == homePath+"/crash"
396+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:
397+ return strings.Trim(u.Path, "/") == "crash"
398+ default:
399+ return false
400+ }
401+}
402+
403+func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
404+ return webutil.SanitizeCrashPageOriginalURI(originalURI)
405+}
406+
407+func buildCEFCrashPageHTML(originalURI string) string {
408+ return webutil.BuildCrashPageHTML(originalURI)
409+}
410+
411+func getMimeType(filename string) string {
412+ return webutil.GetMimeType(filename)
413+}
414+
415+// splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
416+// If no charset parameter is present, charset is empty.
417+func splitMimeCharset(contentType string) (mimeType, charset string) {
418+ parts := strings.SplitN(contentType, ";", 2)
419+ mimeType = strings.TrimSpace(parts[0])
420+ if len(parts) > 1 {
421+ param := strings.TrimSpace(parts[1])
422+ if strings.HasPrefix(strings.ToLower(param), "charset=") {
423+ charset = strings.TrimSpace(param[len("charset="):])
424+ }
425+ }
426+ return
427+}
428+
429+// ---------------------------------------------------------------------------
430+// ResourceHandler implementation
431+// ---------------------------------------------------------------------------
432+
433+// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
434+type staticResourceHandler struct {
435+ data []byte
436+ contentType string
437+ statusCode int
438+ headers map[string]string
439+ offset int
440+}
441+
442+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
443+ return purecef.NewResourceHandler(&staticResourceHandler{
444+ data: data,
445+ contentType: contentType,
446+ statusCode: status,
447+ })
448+}
449+
450+func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
451+ escaped := html.EscapeString(msg)
452+ body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
453+ return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
454+}
455+
456+func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {
457+ data, err := json.Marshal(v)
458+ if err != nil {
459+ return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed")
460+ }
461+ return h.newRawResourceHandler(status, "application/json", data)
462+}
463+
464+// Open handles the request immediately (synchronous).
465+func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, _ purecef.Callback) int32 {
466+ // Set handleRequest = true (1) to indicate we handle it immediately.
467+ *(*int32)(handleRequest) = 1
468+ return 1
469+}
470+
471+// ProcessRequest is deprecated; Open is used instead.
472+func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
473+ return 0
474+}
475+
476+// GetResponseHeaders sets status code, MIME type, and content length.
477+// CEF's SetMimeType expects the MIME type without charset parameters;
478+// the charset must be set separately via SetCharset.
479+func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
480+ response.SetStatus(int32(rh.statusCode))
481+ if text := http.StatusText(rh.statusCode); text != "" {
482+ response.SetStatusText(text)
483+ }
484+ mimeType, charset := splitMimeCharset(rh.contentType)
485+ response.SetMimeType(mimeType)
486+ if charset != "" {
487+ response.SetCharset(charset)
488+ }
489+ for name, value := range rh.headers {
490+ response.SetHeaderByName(name, value, 1)
491+ }
492+ *(*int64)(responseLength) = int64(len(rh.data))
493+}
494+
495+// Skip is not used for static content.
496+func (rh *staticResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
497+ return 0
498+}
499+
500+// Read copies data into the output buffer.
501+func (rh *staticResourceHandler) Read(
502+ dataOut unsafe.Pointer, bytesToRead int32,
503+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
504+) int32 {
505+ if rh.offset >= len(rh.data) {
506+ return 0
507+ }
508+
509+ remaining := len(rh.data) - rh.offset
510+ toRead := int(bytesToRead)
511+ if toRead > remaining {
512+ toRead = remaining
513+ }
514+
515+ // Copy data to the output buffer.
516+ dst := unsafe.Slice((*byte)(dataOut), toRead)
517+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
518+ rh.offset += toRead
519+
520+ *(*int32)(bytesRead) = int32(toRead)
521+ return 1
522+}
523+
524+// ReadResponse is deprecated; Read is used instead.
525+func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
526+ return 0
527+}
528+
529+// Cancel is a no-op for static content.
530+func (rh *staticResourceHandler) Cancel() {}
531+
532+// ---------------------------------------------------------------------------
533+// Request body reader
534+// ---------------------------------------------------------------------------
535+
536+// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
537+// The JS bridge base64-encodes the JSON body into this header to avoid the
538+// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
539+func readBodyFromHeader(request purecef.Request) []byte {
540+ encoded := request.GetHeaderByName("X-Dumber-Body")
541+ if encoded == "" {
542+ return nil
543+ }
544+ body, err := base64.StdEncoding.DecodeString(encoded)
545+ if err != nil {
546+ return nil
547+ }
548+ return body
549+}
A internal/infrastructure/cef/scheme_handler_test.go +45 -0
@@ -0,0 +1,45 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
12+ data, err := resolveConfigPayload(func() ([]byte, error) {
13+ return []byte(`{"engine_type":"cef"}`), nil
14+ })
15+ require.NoError(t, err)
16+ require.JSONEq(t, `{"engine_type":"cef"}`, string(data))
17+}
18+
19+func TestResolveConfigPayload_PropagatesBuilderError(t *testing.T) {
20+ want := errors.New("boom")
21+ _, err := resolveConfigPayload(func() ([]byte, error) {
22+ return nil, want
23+ })
24+ require.ErrorIs(t, err, want)
25+}
26+
27+func TestResolveConfigPayload_NilBuilderFails(t *testing.T) {
28+ _, err := resolveConfigPayload(nil)
29+ require.Error(t, err)
30+ require.Contains(t, err.Error(), "config payload builder")
31+}
32+
33+func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) {
34+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, nil, func() ([]byte, error) { return nil, nil })
35+ require.Error(t, err)
36+ require.Nil(t, h)
37+ require.Contains(t, err.Error(), "current config payload builder")
38+}
39+
40+func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
41+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }, nil)
42+ require.Error(t, err)
43+ require.Nil(t, h)
44+ require.Contains(t, err.Error(), "default config payload builder")
45+}
A internal/infrastructure/cef/transcoding_handler.go +412 -0
@@ -0,0 +1,412 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "time"
9+ "unsafe"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/logging"
15+ "github.com/rs/zerolog"
16+)
17+
18+// ---------------------------------------------------------------------------
19+// Compile-time interface checks
20+// ---------------------------------------------------------------------------
21+
22+var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
23+
24+const (
25+ maxTranscodingURLLength = 240
26+ httpStatusOK = 200
27+)
28+
29+// transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
30+// accepts a purecef.ResourceHandler, and the struct implements all methods.
31+
32+// ---------------------------------------------------------------------------
33+// transcodingResourceHandler — serves a transcoded WebM stream to CEF
34+// ---------------------------------------------------------------------------
35+
36+type transcodingResourceHandler struct {
37+ transcoder port.MediaTranscoder
38+ sourceURL string
39+ headers map[string]string
40+ session port.TranscodeSession
41+ ctx context.Context
42+ cancel context.CancelFunc
43+ logf func() zerolog.Logger
44+ totalBytes int64
45+}
46+
47+// Open starts the transcode session asynchronously. CEF will call
48+// GetResponseHeaders and Read once the callback fires.
49+func (rh *transcodingResourceHandler) Open(
50+ _ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback,
51+) int32 {
52+ // Signal async handling: set handleRequest = 0.
53+ *(*int32)(handleRequest) = 0
54+
55+ log := rh.logger()
56+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
57+ log.Info().
58+ Str("source_url", sourceURL).
59+ Int("forwarded_header_count", len(rh.headers)).
60+ Msg("cef: starting transcoding resource stream")
61+
62+ go func() {
63+ session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
64+ if err != nil {
65+ log.Error().
66+ Err(err).
67+ Str("source_url", sourceURL).
68+ Msg("cef: failed to start transcoding resource stream")
69+ // Let CEF know we failed — Cancel will be called.
70+ rh.cancel()
71+ callback.Cont()
72+ return
73+ }
74+ rh.session = session
75+ log.Info().
76+ Str("source_url", sourceURL).
77+ Str("content_type", session.ContentType()).
78+ Msg("cef: transcoding resource stream ready")
79+ callback.Cont()
80+ }()
81+
82+ return 1
83+}
84+
85+// GetResponseHeaders sets the streaming response metadata.
86+func (rh *transcodingResourceHandler) GetResponseHeaders(
87+ response purecef.Response, responseLength unsafe.Pointer, _ uintptr,
88+) {
89+ response.SetStatus(httpStatusOK)
90+ response.SetStatusText("OK")
91+ response.SetMimeType("video/webm")
92+ response.SetHeaderByName("Accept-Ranges", "none", 1)
93+ // CORS wildcard is required because transcoded streams are loaded by <video>
94+ // elements on arbitrary origins (e.g. reddit.com). The streams contain no
95+ // sensitive data — they are re-encoded from publicly-accessible media URLs.
96+ response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)
97+ response.SetHeaderByName("Cache-Control", "no-store", 1)
98+ // Streaming — unknown length.
99+ *(*int64)(responseLength) = -1
100+}
101+
102+// Read copies transcoded data from the session into the CEF output buffer.
103+func (rh *transcodingResourceHandler) Read(
104+ dataOut unsafe.Pointer, bytesToRead int32,
105+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
106+) int32 {
107+ if rh.session == nil {
108+ return 0
109+ }
110+
111+ dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
112+ n, err := rh.session.Read(dst)
113+ if n > 0 {
114+ rh.totalBytes += int64(n)
115+ if rh.totalBytes == int64(n) {
116+ rh.logger().Info().
117+ Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
118+ Int("first_chunk_bytes", n).
119+ Msg("cef: transcoding resource stream produced first bytes")
120+ }
121+ *(*int32)(bytesRead) = int32(n)
122+ return 1
123+ }
124+ if err != nil {
125+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
126+ log := rh.logger().With().
127+ Str("source_url", sourceURL).
128+ Int64("total_bytes", rh.totalBytes).
129+ Logger()
130+ switch {
131+ case errors.Is(err, io.EOF) && rh.totalBytes == 0:
132+ log.Warn().Msg("cef: transcoding resource stream ended before producing data")
133+ case errors.Is(err, io.EOF):
134+ log.Debug().Msg("cef: transcoding resource stream reached EOF")
135+ default:
136+ log.Error().Err(err).Msg("cef: transcoding resource stream read failed")
137+ }
138+ return 0 // EOF or error
139+ }
140+ return 0
141+}
142+
143+// Cancel aborts the transcode session and releases resources.
144+func (rh *transcodingResourceHandler) Cancel() {
145+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
146+ rh.logger().Debug().
147+ Str("source_url", sourceURL).
148+ Int64("total_bytes", rh.totalBytes).
149+ Msg("cef: transcoding resource stream canceled")
150+ if rh.session != nil {
151+ if err := rh.session.Close(); err != nil {
152+ rh.logger().Warn().
153+ Str("source_url", sourceURL).
154+ Err(err).
155+ Msg("cef: transcoding resource stream close failed")
156+ }
157+ }
158+ if rh.cancel != nil {
159+ rh.cancel()
160+ }
161+}
162+
163+// ProcessRequest is deprecated; Open is used instead.
164+func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
165+ return 0
166+}
167+
168+// ReadResponse is deprecated; Read is used instead.
169+func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
170+ return 0
171+}
172+
173+// Skip is not used for streaming content.
174+func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
175+ return 0
176+}
177+
178+// ---------------------------------------------------------------------------
179+// transcodingRequestHandler — ResourceRequestHandler that intercepts
180+// proprietary video responses and streaming manifests, then restarts them
181+// through the transcoder
182+// ---------------------------------------------------------------------------
183+
184+type transcodingRequestHandler struct {
185+ transcoder port.MediaTranscoder
186+ classifier MediaClassifier
187+ transcodableURLs sync.Map // url string -> requestInfo
188+ ctxf func() context.Context
189+}
190+
191+type requestInfo struct {
192+ headers map[string]string
193+}
194+
195+// newTranscodingRequestHandler creates a ResourceRequestHandler that detects
196+// proprietary video containers and HLS/DASH manifest entrypoints, then
197+// restarts those requests through a GPU transcoding ResourceHandler.
198+func newTranscodingRequestHandler(
199+ tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context,
200+) purecef.ResourceRequestHandler {
201+ return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
202+ transcoder: tc,
203+ classifier: classifier,
204+ ctxf: ctxf,
205+ })
206+}
207+
208+// OnResourceResponse inspects the MIME type and URL of every response. If it
209+// matches a proprietary video format or a streaming manifest entrypoint, it
210+// stores the request info and returns 1 to restart the request — at which
211+// point GetResourceHandler will provide the transcoding handler.
212+func (h *transcodingRequestHandler) OnResourceResponse(
213+ _ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response,
214+) int32 {
215+ if response == nil || request == nil {
216+ return 0
217+ }
218+ mimeType := response.GetMimeType()
219+ url := request.GetURL()
220+
221+ log := h.logger()
222+ isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType)
223+ if isProprietary || isAlreadyOpen || isManifest {
224+ log.Debug().
225+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
226+ Str("mime_type", mimeType).
227+ Bool("proprietary_match", isProprietary).
228+ Bool("already_open", isAlreadyOpen).
229+ Bool("manifest_match", isManifest).
230+ Msg("cef: OnResourceResponse observed media response")
231+ }
232+
233+ if isAlreadyOpen {
234+ return 0
235+ }
236+
237+ if !isProprietary && !isManifest {
238+ return 0
239+ }
240+
241+ info := buildRequestInfo(request)
242+ h.transcodableURLs.Store(url, info)
243+ reason := "proprietary-container"
244+ if isManifest {
245+ reason = "streaming-manifest"
246+ }
247+ log.Info().
248+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
249+ Str("mime_type", mimeType).
250+ Str("transcode_reason", reason).
251+ Int("forwarded_header_count", len(info.headers)).
252+ Msg("cef: media response marked for transcoding")
253+
254+ return 1 // restart request
255+}
256+
257+// GetResourceHandler returns a transcoding ResourceHandler for URLs that were
258+// either identified eagerly by URL heuristics or marked by OnResourceResponse.
259+func (h *transcodingRequestHandler) GetResourceHandler(
260+ _ purecef.Browser, _ purecef.Frame, request purecef.Request,
261+) purecef.ResourceHandler {
262+ if request == nil {
263+ return nil
264+ }
265+ url := request.GetURL()
266+ val, ok := h.transcodableURLs.LoadAndDelete(url)
267+ info := requestInfo{}
268+ eager := false
269+ sourceURL := url
270+ if ok {
271+ cached, typeOK := val.(requestInfo)
272+ if !typeOK {
273+ return nil
274+ }
275+ info = cached
276+ } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic {
277+ eager = true
278+ sourceURL = syntheticSourceURL
279+ info = buildRequestInfo(request)
280+ if referer != "" && info.headers["Referer"] == "" {
281+ info.headers["Referer"] = referer
282+ }
283+ if origin != "" && info.headers["Origin"] == "" {
284+ info.headers["Origin"] = origin
285+ }
286+ } else {
287+ return nil
288+ }
289+
290+ parentCtx := context.Background()
291+ if h.ctxf != nil {
292+ if provided := h.ctxf(); provided != nil {
293+ parentCtx = provided
294+ }
295+ }
296+ ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute)
297+ shortURL := logging.TruncateURL(url, maxTranscodingURLLength)
298+ shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength)
299+ h.logger().Info().
300+ Str("url", shortURL).
301+ Str("source_url", shortSourceURL).
302+ Bool("eager_match", eager).
303+ Int("forwarded_header_count", len(info.headers)).
304+ Msg("cef: GetResourceHandler returning transcoding resource handler")
305+ return purecef.NewResourceHandler(&transcodingResourceHandler{
306+ transcoder: h.transcoder,
307+ sourceURL: sourceURL,
308+ headers: info.headers,
309+ ctx: ctx,
310+ cancel: cancel,
311+ logf: h.logf,
312+ })
313+}
314+
315+func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) {
316+ proprietary := classifier.IsProprietaryVideoMIME(mimeType)
317+ alreadyOpen := classifier.IsOpenVideoMIME(mimeType)
318+ manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL))
319+ return proprietary, alreadyOpen, manifest
320+}
321+
322+func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) {
323+ if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok {
324+ return sourceURL, referer, origin, true
325+ }
326+ if classifier.IsEagerTranscodeURL(rawURL) {
327+ return rawURL, "", "", true
328+ }
329+ return "", "", "", false
330+}
331+
332+// --- No-op methods required by purecef.ResourceRequestHandler ---
333+
334+func (h *transcodingRequestHandler) GetCookieAccessFilter(
335+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
336+) purecef.CookieAccessFilter {
337+ return nil
338+}
339+
340+func (h *transcodingRequestHandler) OnBeforeResourceLoad(
341+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback,
342+) purecef.ReturnValue {
343+ return purecef.ReturnValueRvContinue
344+}
345+
346+func (h *transcodingRequestHandler) OnResourceRedirect(
347+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr,
348+) {
349+}
350+
351+func (h *transcodingRequestHandler) GetResourceResponseFilter(
352+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response,
353+) purecef.ResponseFilter {
354+ return nil
355+}
356+
357+func (h *transcodingRequestHandler) OnResourceLoadComplete(
358+ _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response,
359+ _ purecef.UrlrequestStatus, _ int64,
360+) {
361+}
362+
363+func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
364+}
365+
366+func buildRequestInfo(request purecef.Request) requestInfo {
367+ headers := make(map[string]string)
368+ if request == nil {
369+ return requestInfo{headers: headers}
370+ }
371+ if cookie := request.GetHeaderByName("Cookie"); cookie != "" {
372+ headers["Cookie"] = cookie
373+ }
374+ if auth := request.GetHeaderByName("Authorization"); auth != "" {
375+ headers["Authorization"] = auth
376+ }
377+ if referer := request.GetHeaderByName("Referer"); referer != "" {
378+ headers["Referer"] = referer
379+ }
380+ if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
381+ headers["User-Agent"] = userAgent
382+ }
383+ if origin := request.GetHeaderByName("Origin"); origin != "" {
384+ headers["Origin"] = origin
385+ }
386+ return requestInfo{headers: headers}
387+}
388+
389+func (h *transcodingRequestHandler) logger() *zerolog.Logger {
390+ logger := h.logf()
391+ return &logger
392+}
393+
394+func (h *transcodingRequestHandler) logf() zerolog.Logger {
395+ ctx := context.Background()
396+ if h != nil && h.ctxf != nil {
397+ if provided := h.ctxf(); provided != nil {
398+ ctx = provided
399+ }
400+ }
401+ return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger()
402+}
403+
404+func (rh *transcodingResourceHandler) logger() *zerolog.Logger {
405+ if rh != nil && rh.logf != nil {
406+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
407+ logger := rh.logf().With().Str("source_url", sourceURL).Logger()
408+ return &logger
409+ }
410+ logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger()
411+ return &logger
412+}
A internal/infrastructure/cef/transcoding_handler_test.go +49 -0
@@ -0,0 +1,49 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) {
10+ classifier := MediaClassifier{
11+ IsProprietaryVideoMIME: func(string) bool { return true },
12+ IsOpenVideoMIME: func(string) bool { return false },
13+ IsStreamingManifestMIME: func(string) bool { return false },
14+ IsStreamingManifestURL: func(string) bool { return false },
15+ }.normalize()
16+
17+ proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4")
18+ require.True(t, proprietary)
19+ require.False(t, alreadyOpen)
20+ require.False(t, manifest)
21+}
22+
23+func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) {
24+ classifier := MediaClassifier{
25+ ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) {
26+ return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true
27+ },
28+ IsEagerTranscodeURL: func(string) bool { return false },
29+ }.normalize()
30+
31+ src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x")
32+ require.True(t, eager)
33+ require.Equal(t, "https://cdn.example/video.mp4", src)
34+ require.Equal(t, "https://example.com", referer)
35+ require.Equal(t, "https://example.com", origin)
36+}
37+
38+func TestResolveTranscodeSource_UsesEagerFallback(t *testing.T) {
39+ classifier := MediaClassifier{
40+ ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { return "", "", "", false },
41+ IsEagerTranscodeURL: func(string) bool { return true },
42+ }.normalize()
43+
44+ src, referer, origin, eager := resolveTranscodeSource(classifier, "https://cdn.example/video.mp4")
45+ require.True(t, eager)
46+ require.Equal(t, "https://cdn.example/video.mp4", src)
47+ require.Empty(t, referer)
48+ require.Empty(t, origin)
49+}
A internal/infrastructure/cef/webview.go +735 -0
@@ -0,0 +1,735 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "math"
9+ "sync"
10+ "sync/atomic"
11+
12+ purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/logging"
18+)
19+
20+// Compile-time interface checks.
21+var (
22+ _ port.WebView = (*WebView)(nil)
23+ _ port.NativeWidgetProvider = (*WebView)(nil)
24+ _ port.DevToolsOpener = (*WebView)(nil)
25+)
26+
27+// errDestroyed is returned when an operation is attempted on a destroyed WebView.
28+var errDestroyed = errors.New("cef: webview is destroyed")
29+
30+// errNoBrowser is returned when the browser has not been created yet.
31+var errNoBrowser = errors.New("cef: browser not yet created")
32+
33+// WebView implements port.WebView using a CEF off-screen browser rendered
34+// through a renderPipeline and driven by an inputBridge.
35+type WebView struct {
36+ id port.WebViewID
37+ ctx context.Context
38+ engine *Engine
39+ browser purecef.Browser
40+ host purecef.BrowserHost
41+ client purecef.Client // prevent GC from collecting the client before CEF AddRef's it
42+ pipeline *renderPipeline
43+ input *inputBridge
44+ handlers *handlerSet
45+ findCtrl *cefFindController
46+
47+ // beginFrameTick drives CEF external BeginFrame requests while the GTK
48+ // widget is visible. Access is guarded by mu.
49+ beginFrameTick *gtk.TickCallback
50+ beginFrameTickID uint
51+
52+ // pendingCreate holds browser creation params until the GL area is realized.
53+ pendingCreate *pendingBrowserCreate
54+
55+ // pendingURI is set when LoadURI is called before the browser exists.
56+ pendingURI string
57+
58+ // crashCount tracks consecutive renderer crashes to prevent infinite
59+ // crash → redirect → crash loops.
60+ crashCount atomic.Int32
61+
62+ // Callbacks set by use case layer.
63+ mu sync.RWMutex
64+ callbacks *port.WebViewCallbacks
65+
66+ // State cache (mutex-protected).
67+ uri string
68+ title string
69+ progress float64
70+ canGoBack bool
71+ canGoFwd bool
72+ isLoading bool
73+
74+ // Last known hover URI for middle-click → new tab.
75+ lastHoverURI string
76+
77+ // Atomic state.
78+ destroyed atomic.Bool
79+ fullscreen atomic.Bool
80+ generation atomic.Uint64
81+ audioPlaying atomic.Bool
82+ zoomFactor atomic.Value // float64, initialized to 1.0
83+
84+ // Audio output factory and active stream.
85+ audioOutputFactory port.AudioOutputFactory
86+ audioStreamMu sync.Mutex
87+ activeAudioStream port.AudioOutputStream
88+
89+ // Audio instrumentation counters (diagnostic only).
90+ audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
91+ audioWriteCount atomic.Uint64 // successful Write calls to stream
92+}
93+
94+// pendingBrowserCreate holds the parameters needed to create a CEF browser,
95+// deferred until the GL area has a non-zero size.
96+type pendingBrowserCreate struct {
97+ windowInfo *purecef.WindowInfo
98+ client purecef.Client
99+ settings *purecef.BrowserSettings
100+}
101+
102+type cefTaskFunc func()
103+
104+func (fn cefTaskFunc) Execute() {
105+ if fn != nil {
106+ fn()
107+ }
108+}
109+
110+// ---------------------------------------------------------------------------
111+// Identity
112+// ---------------------------------------------------------------------------
113+
114+func (wv *WebView) ID() port.WebViewID {
115+ return wv.id
116+}
117+
118+// ---------------------------------------------------------------------------
119+// Navigation
120+// ---------------------------------------------------------------------------
121+
122+func (wv *WebView) LoadURI(_ context.Context, uri string) error {
123+ if wv.destroyed.Load() {
124+ return errDestroyed
125+ }
126+ actualURI := toActualInternalURL(uri)
127+ wv.mu.Lock()
128+ browser := wv.browser
129+ if browser == nil {
130+ // Browser not yet created — queue the URI for OnAfterCreated.
131+ wv.pendingURI = actualURI
132+ wv.mu.Unlock()
133+ return nil
134+ }
135+ wv.mu.Unlock()
136+ if frame := browser.GetMainFrame(); frame != nil {
137+ frame.LoadURL(actualURI)
138+ }
139+ return nil
140+}
141+
142+// LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
143+func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
144+ if wv.destroyed.Load() {
145+ return errDestroyed
146+ }
147+ const maxDataURLSize = 1 << 20 // 1MB
148+ if len(content) > maxDataURLSize {
149+ logging.FromContext(ctx).Warn().
150+ Int("content_len", len(content)).
151+ Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
152+ }
153+ wv.mu.RLock()
154+ browser := wv.browser
155+ wv.mu.RUnlock()
156+ if browser == nil {
157+ return errNoBrowser
158+ }
159+ dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
160+ if frame := browser.GetMainFrame(); frame != nil {
161+ frame.LoadURL(dataURL)
162+ }
163+ return nil
164+}
165+
166+func (wv *WebView) Reload(_ context.Context) error {
167+ if wv.destroyed.Load() {
168+ return errDestroyed
169+ }
170+ wv.mu.RLock()
171+ browser := wv.browser
172+ wv.mu.RUnlock()
173+ if browser == nil {
174+ return errNoBrowser
175+ }
176+ browser.Reload()
177+ return nil
178+}
179+
180+// ReloadBypassCache reloads the current page, bypassing cache.
181+func (wv *WebView) ReloadBypassCache(_ context.Context) error {
182+ if wv.destroyed.Load() {
183+ return errDestroyed
184+ }
185+ wv.mu.RLock()
186+ browser := wv.browser
187+ wv.mu.RUnlock()
188+ if browser == nil {
189+ return errNoBrowser
190+ }
191+ browser.ReloadIgnoreCache()
192+ return nil
193+}
194+
195+// Stop stops the current page load.
196+func (wv *WebView) Stop(_ context.Context) error {
197+ if wv.destroyed.Load() {
198+ return errDestroyed
199+ }
200+ wv.mu.RLock()
201+ browser := wv.browser
202+ wv.mu.RUnlock()
203+ if browser == nil {
204+ return errNoBrowser
205+ }
206+ browser.StopLoad()
207+ return nil
208+}
209+
210+// GoBack navigates back in history.
211+func (wv *WebView) GoBack(_ context.Context) error {
212+ if wv.destroyed.Load() {
213+ return errDestroyed
214+ }
215+ wv.mu.RLock()
216+ browser := wv.browser
217+ wv.mu.RUnlock()
218+ if browser == nil {
219+ return errNoBrowser
220+ }
221+ browser.GoBack()
222+ return nil
223+}
224+
225+// GoForward navigates forward in history.
226+func (wv *WebView) GoForward(_ context.Context) error {
227+ if wv.destroyed.Load() {
228+ return errDestroyed
229+ }
230+ wv.mu.RLock()
231+ browser := wv.browser
232+ wv.mu.RUnlock()
233+ if browser == nil {
234+ return errNoBrowser
235+ }
236+ browser.GoForward()
237+ return nil
238+}
239+
240+// ---------------------------------------------------------------------------
241+// State queries (read from cache under RLock)
242+// ---------------------------------------------------------------------------
243+
244+func (wv *WebView) URI() string {
245+ wv.mu.RLock()
246+ defer wv.mu.RUnlock()
247+ return wv.uri
248+}
249+
250+func (wv *WebView) Title() string {
251+ wv.mu.RLock()
252+ defer wv.mu.RUnlock()
253+ return wv.title
254+}
255+
256+func (wv *WebView) IsLoading() bool {
257+ wv.mu.RLock()
258+ defer wv.mu.RUnlock()
259+ return wv.isLoading
260+}
261+
262+// EstimatedProgress returns the load progress (0.0 to 1.0).
263+func (wv *WebView) EstimatedProgress() float64 {
264+ wv.mu.RLock()
265+ defer wv.mu.RUnlock()
266+ return wv.progress
267+}
268+
269+// CanGoBack returns true if back navigation is available.
270+func (wv *WebView) CanGoBack() bool {
271+ wv.mu.RLock()
272+ defer wv.mu.RUnlock()
273+ return wv.canGoBack
274+}
275+
276+// CanGoForward returns true if forward navigation is available.
277+func (wv *WebView) CanGoForward() bool {
278+ wv.mu.RLock()
279+ defer wv.mu.RUnlock()
280+ return wv.canGoFwd
281+}
282+
283+// State returns the current WebView state as a snapshot.
284+func (wv *WebView) State() port.WebViewState {
285+ wv.mu.RLock()
286+ defer wv.mu.RUnlock()
287+ return port.WebViewState{
288+ URI: wv.uri,
289+ Title: wv.title,
290+ IsLoading: wv.isLoading,
291+ Progress: wv.progress,
292+ CanGoBack: wv.canGoBack,
293+ CanGoFwd: wv.canGoFwd,
294+ ZoomLevel: wv.GetZoomLevel(),
295+ }
296+}
297+
298+// IsFullscreen returns true if the WebView is currently in fullscreen mode.
299+func (wv *WebView) IsFullscreen() bool {
300+ return wv.fullscreen.Load()
301+}
302+
303+// IsPlayingAudio returns true if any audio stream is active.
304+func (wv *WebView) IsPlayingAudio() bool {
305+ return wv.audioPlaying.Load()
306+}
307+
308+// Generation returns a monotonic counter incremented on pool reuse.
309+func (wv *WebView) Generation() uint64 {
310+ return wv.generation.Load()
311+}
312+
313+// Favicon returns nil (Phase 1 stub).
314+func (wv *WebView) Favicon() port.Texture {
315+ return nil
316+}
317+
318+// ---------------------------------------------------------------------------
319+// Zoom
320+// ---------------------------------------------------------------------------
321+
322+// chromiumZoomBase is the base used by Chromium's logarithmic zoom level.
323+// CEF zoom level = log(factor) / log(1.2), where factor is the linear multiplier.
324+const chromiumZoomBase = 1.2
325+
326+// cefZoomFromFactor converts a linear zoom factor (1.0 = 100%) to CEF's
327+// logarithmic zoom level.
328+func cefZoomFromFactor(factor float64) float64 {
329+ if factor <= 0 {
330+ return 0
331+ }
332+ return math.Log(factor) / math.Log(chromiumZoomBase)
333+}
334+
335+// factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
336+// linear zoom factor.
337+func factorFromCEFZoom(level float64) float64 {
338+ return math.Pow(chromiumZoomBase, level)
339+}
340+
341+// GetZoomLevel returns the current linear zoom factor.
342+func (wv *WebView) GetZoomLevel() float64 {
343+ if v := wv.zoomFactor.Load(); v != nil {
344+ return v.(float64)
345+ }
346+ return 1.0
347+}
348+
349+// SetZoomLevel sets the zoom level using a linear factor (1.0 = 100%).
350+func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
351+ if wv.destroyed.Load() {
352+ return errDestroyed
353+ }
354+ wv.mu.RLock()
355+ host := wv.host
356+ wv.mu.RUnlock()
357+ if host == nil {
358+ return errNoBrowser
359+ }
360+ cefLevel := cefZoomFromFactor(factor)
361+ logging.FromContext(wv.ctx).Debug().
362+ Float64("factor", factor).
363+ Float64("cef_level", cefLevel).
364+ Msg("cef: SetZoomLevel")
365+ host.SetZoomLevel(cefLevel)
366+ wv.zoomFactor.Store(factor)
367+ // Force CEF to produce a new frame at the new zoom level. In OSR mode,
368+ // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
369+ // repaint. WasResized is a no-op when view dimensions haven't changed.
370+ // NotifyScreenInfoChanged forces surface ID invalidation + a full
371+ // SynchronizeVisualProperties cycle, which makes the renderer produce
372+ // a new compositor frame at the new zoom level.
373+ host.NotifyScreenInfoChanged()
374+ // Zoom is applied asynchronously in the renderer process. Request a couple
375+ // of follow-up refreshes on the CEF UI thread so OSR captures the updated
376+ // compositor frame after the zoom IPC has been processed.
377+ wv.scheduleZoomRefresh()
378+ wv.scheduleZoomReadback(factor, cefLevel)
379+ return nil
380+}
381+
382+// ---------------------------------------------------------------------------
383+// DevTools
384+// ---------------------------------------------------------------------------
385+
386+// OpenDevTools opens the Chromium DevTools in a separate window.
387+func (wv *WebView) OpenDevTools() {
388+ if wv.destroyed.Load() {
389+ return
390+ }
391+ wv.mu.RLock()
392+ host := wv.host
393+ wv.mu.RUnlock()
394+ if host == nil {
395+ return
396+ }
397+ windowInfo := purecef.NewWindowInfo()
398+ settings := purecef.NewBrowserSettings()
399+ host.ShowDevTools(&windowInfo, nil, &settings, nil)
400+}
401+
402+// ---------------------------------------------------------------------------
403+// Find
404+// ---------------------------------------------------------------------------
405+
406+// GetFindController returns the find-in-page controller.
407+func (wv *WebView) GetFindController() port.FindController {
408+ if wv.findCtrl == nil {
409+ return nil
410+ }
411+ return wv.findCtrl
412+}
413+
414+// ---------------------------------------------------------------------------
415+// Callbacks
416+// ---------------------------------------------------------------------------
417+
418+// SetCallbacks registers callback handlers for WebView events.
419+func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
420+ wv.mu.Lock()
421+ defer wv.mu.Unlock()
422+ wv.callbacks = cb
423+}
424+
425+// ---------------------------------------------------------------------------
426+// JavaScript / Appearance
427+// ---------------------------------------------------------------------------
428+
429+// RunJavaScript executes a script in the main world. Fire-and-forget.
430+func (wv *WebView) RunJavaScript(_ context.Context, script string) {
431+ if wv.destroyed.Load() {
432+ return
433+ }
434+ wv.mu.RLock()
435+ browser := wv.browser
436+ wv.mu.RUnlock()
437+ if browser == nil {
438+ return
439+ }
440+ if frame := browser.GetMainFrame(); frame != nil {
441+ frame.ExecuteJavaScript(script, "", 0)
442+ }
443+}
444+
445+// colorScale converts a 0.0–1.0 color component to an 8-bit integer.
446+const colorScale = 255
447+
448+// SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
449+func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
450+ script := fmt.Sprintf(
451+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'; } })()`,
452+ int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
453+ )
454+ wv.RunJavaScript(context.Background(), script)
455+}
456+
457+// ResetBackgroundToDefault clears the injected background color.
458+func (wv *WebView) ResetBackgroundToDefault() {
459+ wv.RunJavaScript(context.Background(),
460+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor=''; } })()`)
461+}
462+
463+// ---------------------------------------------------------------------------
464+// Lifecycle
465+// ---------------------------------------------------------------------------
466+
467+func (wv *WebView) IsDestroyed() bool {
468+ return wv.destroyed.Load()
469+}
470+
471+func (wv *WebView) Destroy() {
472+ if !wv.destroyed.CompareAndSwap(false, true) {
473+ return
474+ }
475+ wv.closeAudioStream()
476+ wv.scheduleStopBeginFrameLoop()
477+ wv.mu.RLock()
478+ host := wv.host
479+ wv.mu.RUnlock()
480+ if host != nil {
481+ host.CloseBrowser(1)
482+ }
483+ if wv.pipeline != nil {
484+ wv.pipeline.destroy()
485+ }
486+}
487+
488+// ---------------------------------------------------------------------------
489+// NativeWidgetProvider
490+// ---------------------------------------------------------------------------
491+
492+// NativeWidget returns the uintptr for embedding the GLArea into GTK.
493+func (wv *WebView) NativeWidget() uintptr {
494+ if wv.pipeline == nil || wv.pipeline.glArea == nil {
495+ return 0
496+ }
497+ return wv.pipeline.glArea.GoPointer()
498+}
499+
500+// ---------------------------------------------------------------------------
501+// State update helpers (called from handlers)
502+// ---------------------------------------------------------------------------
503+
504+func (wv *WebView) updateURI(uri string) {
505+ uri = toConceptualInternalURL(uri)
506+ wv.mu.Lock()
507+ wv.uri = uri
508+ cb := wv.callbacks
509+ wv.mu.Unlock()
510+
511+ if cb != nil && cb.OnURIChanged != nil {
512+ wv.runOnGTK(func() {
513+ cb.OnURIChanged(uri)
514+ })
515+ }
516+}
517+
518+func (wv *WebView) updateTitle(title string) {
519+ wv.mu.Lock()
520+ wv.title = title
521+ cb := wv.callbacks
522+ wv.mu.Unlock()
523+
524+ if cb != nil && cb.OnTitleChanged != nil {
525+ wv.runOnGTK(func() {
526+ cb.OnTitleChanged(title)
527+ })
528+ }
529+}
530+
531+func (wv *WebView) updateProgress(progress float64) {
532+ wv.mu.Lock()
533+ wv.progress = progress
534+ cb := wv.callbacks
535+ wv.mu.Unlock()
536+
537+ if cb != nil && cb.OnProgressChanged != nil {
538+ wv.runOnGTK(func() {
539+ cb.OnProgressChanged(progress)
540+ })
541+ }
542+}
543+
544+func (wv *WebView) updateLoadState(loading, back, fwd bool) {
545+ wv.mu.Lock()
546+ wv.isLoading = loading
547+ wv.canGoBack = back
548+ wv.canGoFwd = fwd
549+ wv.mu.Unlock()
550+}
551+
552+func (wv *WebView) setAudioPlaying(playing bool) {
553+ wv.audioPlaying.Store(playing)
554+ wv.mu.RLock()
555+ cb := wv.callbacks
556+ wv.mu.RUnlock()
557+ if cb != nil && cb.OnAudioStateChanged != nil {
558+ wv.runOnGTK(func() {
559+ cb.OnAudioStateChanged(playing)
560+ })
561+ }
562+}
563+
564+func (wv *WebView) updateHoverURI(uri string) {
565+ wv.mu.Lock()
566+ wv.lastHoverURI = uri
567+ wv.mu.Unlock()
568+}
569+
570+// scheduleZoomRefresh posts two invalidation requests to the CEF UI thread:
571+// - 16ms delay: one frame at 60fps, gives the renderer time to process
572+// the zoom IPC before we request the first repaint.
573+// - 48ms delay: three frames at 60fps, a second invalidation to catch
574+// any compositor frame that was still in-flight during the first.
575+//
576+// These values are empirically tuned for CEF's async zoom application.
577+func (wv *WebView) scheduleZoomRefresh() {
578+ for _, delayMs := range [...]int64{16, 48} {
579+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
580+ if wv.destroyed.Load() {
581+ return
582+ }
583+ wv.mu.RLock()
584+ host := wv.host
585+ wv.mu.RUnlock()
586+ if host == nil {
587+ return
588+ }
589+ host.Invalidate(purecef.PaintElementTypePetView)
590+ })), delayMs)
591+ }
592+}
593+
594+// scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied:
595+// - 0ms delay: immediate check on the CEF UI thread to log the zoom level
596+// right after SetZoomLevel returns (usually still the old value).
597+// - 64ms delay: four frames at 60fps, enough time for the renderer to
598+// process the zoom IPC and reflect the new level back to the browser.
599+//
600+// These values are empirically tuned for CEF's async zoom application.
601+func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
602+ for _, delayMs := range [...]int64{0, 64} {
603+ purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
604+ if wv.destroyed.Load() {
605+ return
606+ }
607+ wv.mu.RLock()
608+ host := wv.host
609+ wv.mu.RUnlock()
610+ if host == nil {
611+ return
612+ }
613+ actualLevel := host.GetZoomLevel()
614+ logging.FromContext(wv.ctx).Debug().
615+ Int64("delay_ms", delayMs).
616+ Float64("expected_factor", expectedFactor).
617+ Float64("expected_cef_level", expectedLevel).
618+ Float64("actual_factor", factorFromCEFZoom(actualLevel)).
619+ Float64("actual_cef_level", actualLevel).
620+ Msg("cef: zoom level readback")
621+ })), delayMs)
622+ }
623+}
624+
625+func (wv *WebView) scheduleStartBeginFrameLoop() {
626+ if !externalBeginFrameEnabled() {
627+ return
628+ }
629+ wv.runOnGTK(func() {
630+ wv.startBeginFrameLoop()
631+ })
632+}
633+
634+func (wv *WebView) scheduleStopBeginFrameLoop() {
635+ wv.runOnGTK(func() {
636+ wv.stopBeginFrameLoop()
637+ })
638+}
639+
640+func (wv *WebView) startBeginFrameLoop() {
641+ if wv.destroyed.Load() || wv.pipeline == nil || wv.pipeline.glArea == nil {
642+ return
643+ }
644+
645+ wv.mu.Lock()
646+ if wv.beginFrameTickID != 0 || wv.host == nil {
647+ wv.mu.Unlock()
648+ return
649+ }
650+
651+ glArea := wv.pipeline.glArea
652+ cb := new(gtk.TickCallback)
653+ *cb = func(_, _, _ uintptr) bool {
654+ if wv.destroyed.Load() {
655+ return false
656+ }
657+ wv.mu.RLock()
658+ host := wv.host
659+ wv.mu.RUnlock()
660+ if host == nil {
661+ return false
662+ }
663+ host.SendExternalBeginFrame()
664+ return true
665+ }
666+ wv.beginFrameTick = cb
667+ wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil)
668+ host := wv.host
669+ wv.mu.Unlock()
670+
671+ if host != nil {
672+ host.SendExternalBeginFrame()
673+ }
674+}
675+
676+func (wv *WebView) stopBeginFrameLoop() {
677+ if wv.pipeline == nil || wv.pipeline.glArea == nil {
678+ return
679+ }
680+
681+ wv.mu.Lock()
682+ tickID := wv.beginFrameTickID
683+ wv.beginFrameTickID = 0
684+ wv.beginFrameTick = nil
685+ glArea := wv.pipeline.glArea
686+ wv.mu.Unlock()
687+
688+ if tickID != 0 {
689+ glArea.RemoveTickCallback(tickID)
690+ }
691+}
692+
693+func (wv *WebView) runOnGTK(fn func()) {
694+ if fn == nil {
695+ return
696+ }
697+ // When engine is nil (test/bootstrap), call directly.
698+ if wv.engine == nil {
699+ fn()
700+ return
701+ }
702+
703+ // Heap-allocate the callback so it survives until glib invokes it.
704+ cb := new(glib.SourceOnceFunc)
705+ *cb = func(_ uintptr) {
706+ fn()
707+ }
708+ glib.IdleAddOnce(cb, 0)
709+}
710+
711+func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
712+ wv.mu.Lock()
713+ defer wv.mu.Unlock()
714+ pc := wv.pendingCreate
715+ wv.pendingCreate = nil
716+ return pc
717+}
718+
719+// closeAudioStream closes and clears the active audio output stream.
720+// This is safe to call even if no stream is active.
721+func (wv *WebView) closeAudioStream() {
722+ wv.audioStreamMu.Lock()
723+ defer wv.audioStreamMu.Unlock()
724+
725+ if wv.activeAudioStream != nil {
726+ if err := wv.activeAudioStream.Close(); err != nil {
727+ if wv.ctx != nil {
728+ logging.FromContext(wv.ctx).Debug().
729+ Err(err).
730+ Msg("cef: error closing audio stream")
731+ }
732+ }
733+ wv.activeAudioStream = nil
734+ }
735+}
A internal/infrastructure/cef/webview_audio_test.go +634 -0
@@ -0,0 +1,634 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/infrastructure/audio/null"
11+ purecef "github.com/bnema/purego-cef/cef"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// TestEngine_WiresAudioOutputFactory verifies that the audio output factory
17+// is properly injected through engine initialization.
18+func TestEngine_WiresAudioOutputFactory(t *testing.T) {
19+ // This test verifies the wiring pattern. Since we can't easily
20+ // initialize a full CEF engine in unit tests, we verify the
21+ // webViewFactoryOptions struct accepts the audio factory.
22+ audioFactory := &null.Factory{}
23+
24+ // Verify webViewFactoryOptions can hold the audio factory
25+ opts := webViewFactoryOptions{
26+ scale: 1,
27+ windowlessFrameRate: 60,
28+ audioOutputFactory: audioFactory,
29+ }
30+
31+ require.NotNil(t, opts.audioOutputFactory)
32+ assert.Equal(t, audioFactory, opts.audioOutputFactory)
33+
34+ // Verify the factory is passed through
35+ factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)
36+ require.NotNil(t, factory.audioOutputFactory)
37+}
38+
39+// TestWebViewFactory_AudioFactoryIsOptional verifies that nil audio factory
40+// is handled gracefully (no panic, no audio output).
41+func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) {
42+ // Arrange
43+ opts := webViewFactoryOptions{
44+ scale: 1,
45+ windowlessFrameRate: 60,
46+ audioOutputFactory: nil,
47+ }
48+
49+ // Act
50+ factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)
51+
52+ // Assert
53+ require.NotNil(t, factory)
54+ assert.Nil(t, factory.audioOutputFactory)
55+}
56+
57+// TestHandlerSet_ReceivesAudioFactory verifies the handler set receives
58+// the audio factory from the WebView.
59+func TestHandlerSet_ReceivesAudioFactory(t *testing.T) {
60+ // This test verifies that handlerSet can access the audio factory
61+ // through its associated WebView.
62+ audioFactory := &null.Factory{}
63+
64+ wv := &WebView{
65+ ctx: context.Background(),
66+ audioOutputFactory: audioFactory,
67+ }
68+
69+ handlers := &handlerSet{wv: wv}
70+
71+ // Verify the handler can access the audio factory through the WebView
72+ require.NotNil(t, handlers.wv)
73+ assert.Equal(t, audioFactory, handlers.wv.audioOutputFactory)
74+}
75+
76+// TestWebView_AudioStreamLifecycleFieldsExist verifies the WebView has
77+// the necessary fields for audio stream lifecycle management.
78+func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) {
79+ wv := &WebView{
80+ ctx: context.Background(),
81+ audioOutputFactory: &null.Factory{},
82+ }
83+
84+ // Verify the fields exist and can be set
85+ wv.activeAudioStream = &mockAudioStream{}
86+
87+ assert.NotNil(t, wv.audioOutputFactory)
88+ assert.NotNil(t, wv.activeAudioStream)
89+}
90+
91+// mockAudioStream is a test double for port.AudioOutputStream
92+type mockAudioStream struct {
93+ writeCalled bool
94+ closeCalled bool
95+ writeSamples [][]float32
96+}
97+
98+func (m *mockAudioStream) Write(samples [][]float32) error {
99+ m.writeCalled = true
100+ m.writeSamples = samples
101+ return nil
102+}
103+
104+func (m *mockAudioStream) Close() error {
105+ m.closeCalled = true
106+ return nil
107+}
108+
109+// Verify mock implements the interface
110+var _ port.AudioOutputStream = (*mockAudioStream)(nil)
111+
112+// racyMockAudioStream blocks inside Write until doneCh is closed, allowing
113+// tests to verify that the caller holds a lock across the Write call.
114+type racyMockAudioStream struct {
115+ writeCh chan struct{} // closed when Write is entered
116+ doneCh chan struct{} // Write blocks until this is closed
117+ closeCalled bool
118+}
119+
120+func (m *racyMockAudioStream) Write(_ [][]float32) error {
121+ close(m.writeCh) // signal that Write has been entered
122+ <-m.doneCh // block until test unblocks us
123+ return nil
124+}
125+
126+func (m *racyMockAudioStream) Close() error {
127+ m.closeCalled = true
128+ return nil
129+}
130+
131+var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)
132+
133+// ============================================================================
134+// Task 6: Audio Stream Lifecycle Handling Tests
135+// ============================================================================
136+
137+// TestHandlerSet_OnAudioStreamStarted_CreatesStream verifies that
138+// OnAudioStreamStarted creates a new audio stream from the factory.
139+func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) {
140+ ctx := context.Background()
141+ mockStream := &mockAudioStream{}
142+ mockFactory := &mockAudioFactory{stream: mockStream}
143+
144+ wv := &WebView{
145+ ctx: ctx,
146+ audioOutputFactory: mockFactory,
147+ }
148+
149+ handlers := &handlerSet{wv: wv}
150+
151+ params := &purecef.AudioParameters{
152+ SampleRate: 48000,
153+ ChannelLayout: purecef.ChannelLayoutStereo,
154+ FramesPerBuffer: 512,
155+ }
156+
157+ // Act — third arg is channels, not framesPerBuffer
158+ handlers.OnAudioStreamStarted(nil, params, 2)
159+
160+ // Assert
161+ require.NotNil(t, wv.activeAudioStream)
162+ assert.Equal(t, mockStream, wv.activeAudioStream)
163+ assert.True(t, wv.audioPlaying.Load())
164+
165+ // Verify factory was called with correct format
166+ require.NotNil(t, mockFactory.lastFormat)
167+ assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate)
168+ assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount)
169+ assert.Equal(t, 512, mockFactory.lastFormat.FramesPerBuffer)
170+}
171+
172+// TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic verifies
173+// that OnAudioStreamStarted handles nil factory gracefully.
174+func TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic(t *testing.T) {
175+ wv := &WebView{
176+ ctx: context.Background(),
177+ audioOutputFactory: nil,
178+ }
179+
180+ handlers := &handlerSet{wv: wv}
181+
182+ params := &purecef.AudioParameters{
183+ SampleRate: 48000,
184+ ChannelLayout: purecef.ChannelLayoutStereo,
185+ FramesPerBuffer: 512,
186+ }
187+
188+ // Should not panic
189+ assert.NotPanics(t, func() {
190+ handlers.OnAudioStreamStarted(nil, params, 512)
191+ })
192+
193+ assert.False(t, wv.audioPlaying.Load())
194+}
195+
196+// TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream verifies
197+// that starting a new stream closes any existing one.
198+func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
199+ ctx := context.Background()
200+ oldStream := &mockAudioStream{}
201+ newStream := &mockAudioStream{}
202+ mockFactory := &mockAudioFactory{stream: newStream}
203+
204+ wv := &WebView{
205+ ctx: ctx,
206+ audioOutputFactory: mockFactory,
207+ activeAudioStream: oldStream,
208+ }
209+
210+ handlers := &handlerSet{wv: wv}
211+
212+ params := &purecef.AudioParameters{
213+ SampleRate: 44100,
214+ ChannelLayout: purecef.ChannelLayoutMono,
215+ FramesPerBuffer: 256,
216+ }
217+
218+ // Act — third arg is channels (1 for mono)
219+ handlers.OnAudioStreamStarted(nil, params, 1)
220+
221+ // Assert
222+ assert.True(t, oldStream.closeCalled)
223+ assert.Equal(t, newStream, wv.activeAudioStream)
224+}
225+
226+// TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies
227+// that factory errors are handled without panic and audioPlaying is reset to false.
228+func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testing.T) {
229+ ctx := context.Background()
230+ mockFactory := &mockAudioFactory{err: errors.New("factory error")}
231+
232+ wv := &WebView{
233+ ctx: ctx,
234+ audioOutputFactory: mockFactory,
235+ }
236+
237+ handlers := &handlerSet{wv: wv}
238+
239+ params := &purecef.AudioParameters{
240+ SampleRate: 48000,
241+ ChannelLayout: purecef.ChannelLayoutStereo,
242+ FramesPerBuffer: 512,
243+ }
244+
245+ // Should not panic
246+ assert.NotPanics(t, func() {
247+ handlers.OnAudioStreamStarted(nil, params, 512)
248+ })
249+
250+ // audioPlaying must be false when NewStream fails — if it stays true,
251+ // the UI/state layer incorrectly believes audio is active.
252+ assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure")
253+ assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure")
254+}
255+
256+// ============================================================================
257+// Task 7: PCM Packet Forwarding Tests
258+// ============================================================================
259+
260+// TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that
261+// OnAudioStreamPacket copies samples before writing to avoid data races.
262+func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) {
263+ ctx := context.Background()
264+ mockStream := &mockAudioStream{}
265+
266+ wv := &WebView{
267+ ctx: ctx,
268+ activeAudioStream: mockStream,
269+ }
270+
271+ handlers := &handlerSet{wv: wv}
272+
273+ // Create sample data: 2 channels, 512 frames each
274+ originalData := [][]float32{
275+ make([]float32, 512), // Channel 0
276+ make([]float32, 512), // Channel 1
277+ }
278+ originalData[0][0] = 0.5
279+ originalData[1][0] = -0.5
280+
281+ // Act
282+ handlers.OnAudioStreamPacket(nil, originalData, 512, 0)
283+
284+ // Assert
285+ require.True(t, mockStream.writeCalled)
286+ require.Len(t, mockStream.writeSamples, 2)
287+ require.Len(t, mockStream.writeSamples[0], 512)
288+
289+ // Verify data was copied (values match)
290+ assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001)
291+ assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001)
292+
293+ // Verify it's a copy (different underlying array)
294+ assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0])
295+}
296+
297+// TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies
298+// that packets are silently discarded when no stream is active.
299+func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {
300+ wv := &WebView{
301+ ctx: context.Background(),
302+ activeAudioStream: nil,
303+ }
304+
305+ handlers := &handlerSet{wv: wv}
306+
307+ data := [][]float32{
308+ {0.1, 0.2, 0.3},
309+ {0.4, 0.5, 0.6},
310+ }
311+
312+ // Should not panic
313+ assert.NotPanics(t, func() {
314+ handlers.OnAudioStreamPacket(nil, data, 3, 0)
315+ })
316+}
317+
318+// TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies
319+// that nil data is handled gracefully.
320+func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
321+ mockStream := &mockAudioStream{}
322+ wv := &WebView{
323+ ctx: context.Background(),
324+ activeAudioStream: mockStream,
325+ }
326+
327+ handlers := &handlerSet{wv: wv}
328+
329+ // Should not panic with nil data
330+ assert.NotPanics(t, func() {
331+ handlers.OnAudioStreamPacket(nil, nil, 0, 0)
332+ })
333+}
334+
335+// ============================================================================
336+// Task 8: Stream Cleanup Tests
337+// ============================================================================
338+
339+// TestHandlerSet_OnAudioStreamStopped_ClosesStream verifies that
340+// OnAudioStreamStopped closes the active stream.
341+func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
342+ mockStream := &mockAudioStream{}
343+ wv := &WebView{
344+ ctx: context.Background(),
345+ activeAudioStream: mockStream,
346+ }
347+ wv.audioPlaying.Store(true)
348+
349+ handlers := &handlerSet{wv: wv}
350+
351+ // Act
352+ handlers.OnAudioStreamStopped(nil)
353+
354+ // Assert
355+ assert.True(t, mockStream.closeCalled)
356+ assert.Nil(t, wv.activeAudioStream)
357+ assert.False(t, wv.audioPlaying.Load())
358+}
359+
360+// TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic verifies
361+// that stopping when no stream exists doesn't panic.
362+func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {
363+ wv := &WebView{
364+ ctx: context.Background(),
365+ activeAudioStream: nil,
366+ }
367+ wv.audioPlaying.Store(true)
368+
369+ handlers := &handlerSet{wv: wv}
370+
371+ // Should not panic
372+ assert.NotPanics(t, func() {
373+ handlers.OnAudioStreamStopped(nil)
374+ })
375+
376+ assert.False(t, wv.audioPlaying.Load())
377+}
378+
379+// TestHandlerSet_OnAudioStreamError_ClosesStream verifies that
380+// OnAudioStreamError closes the active stream.
381+func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
382+ mockStream := &mockAudioStream{}
383+ wv := &WebView{
384+ ctx: context.Background(),
385+ activeAudioStream: mockStream,
386+ }
387+ wv.audioPlaying.Store(true)
388+
389+ handlers := &handlerSet{wv: wv}
390+
391+ // Act
392+ handlers.OnAudioStreamError(nil, "test error")
393+
394+ // Assert
395+ assert.True(t, mockStream.closeCalled)
396+ assert.Nil(t, wv.activeAudioStream)
397+ assert.False(t, wv.audioPlaying.Load())
398+}
399+
400+// TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic verifies
401+// that error handling when no stream exists doesn't panic.
402+func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {
403+ wv := &WebView{
404+ ctx: context.Background(),
405+ activeAudioStream: nil,
406+ }
407+ wv.audioPlaying.Store(true)
408+
409+ handlers := &handlerSet{wv: wv}
410+
411+ // Should not panic
412+ assert.NotPanics(t, func() {
413+ handlers.OnAudioStreamError(nil, "test error")
414+ })
415+
416+ assert.False(t, wv.audioPlaying.Load())
417+}
418+
419+// TestWebView_Destroy_ClosesAudioStream verifies that destroying
420+// the WebView closes any active audio stream.
421+func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
422+ mockStream := &mockAudioStream{}
423+ wv := &WebView{
424+ ctx: context.Background(),
425+ activeAudioStream: mockStream,
426+ pipeline: &renderPipeline{},
427+ }
428+ wv.audioPlaying.Store(true)
429+
430+ // Act — Destroy must close the audio stream as part of teardown.
431+ wv.Destroy()
432+
433+ // Assert
434+ assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")
435+ assert.Nil(t, wv.activeAudioStream)
436+}
437+
438+// TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy
439+// handles the case where no audio stream is active.
440+func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) {
441+ wv := &WebView{
442+ ctx: context.Background(),
443+ pipeline: &renderPipeline{},
444+ }
445+
446+ assert.NotPanics(t, func() {
447+ wv.Destroy()
448+ })
449+}
450+
451+// ============================================================================
452+// Callback parameter mapping tests
453+// ============================================================================
454+
455+// TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer verifies that
456+// buildAudioStreamFormat reads FramesPerBuffer from the AudioParameters struct,
457+// NOT from the callback's third argument (which is channels).
458+func TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer(t *testing.T) {
459+ wv := &WebView{ctx: context.Background()}
460+ handlers := &handlerSet{wv: wv}
461+
462+ params := &purecef.AudioParameters{
463+ SampleRate: 48000,
464+ ChannelLayout: purecef.ChannelLayoutStereo,
465+ FramesPerBuffer: 1024,
466+ }
467+ channels := int32(2) // third callback arg is channels, not framesPerBuffer
468+
469+ format := handlers.buildAudioStreamFormat(params, channels)
470+
471+ assert.Equal(t, 48000, format.SampleRate, "sample rate should come from params")
472+ assert.Equal(t, 2, format.ChannelCount, "channel count should match callback channels arg")
473+ assert.Equal(t, 1024, format.FramesPerBuffer, "frames_per_buffer should come from params.FramesPerBuffer, not the channels arg")
474+}
475+
476+// TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout verifies correct
477+// behavior when the callback's channels arg and the layout-derived count differ.
478+// The channels callback arg is the authoritative channel count.
479+func TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout(t *testing.T) {
480+ wv := &WebView{ctx: context.Background()}
481+ handlers := &handlerSet{wv: wv}
482+
483+ params := &purecef.AudioParameters{
484+ SampleRate: 44100,
485+ ChannelLayout: purecef.ChannelLayoutStereo, // layout says 2
486+ FramesPerBuffer: 512,
487+ }
488+ channels := int32(1) // but CEF says 1 channel
489+
490+ format := handlers.buildAudioStreamFormat(params, channels)
491+
492+ assert.Equal(t, 44100, format.SampleRate)
493+ assert.Equal(t, 1, format.ChannelCount, "should use callback channels arg, not layout-derived count")
494+ assert.Equal(t, 512, format.FramesPerBuffer, "should use params.FramesPerBuffer")
495+}
496+
497+// TestOnAudioStreamStarted_CorrectParameterMapping verifies end-to-end that
498+// OnAudioStreamStarted passes the correct values to the factory:
499+// - channels from the callback arg (not from params.FramesPerBuffer)
500+// - framesPerBuffer from params.FramesPerBuffer
501+func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) {
502+ ctx := context.Background()
503+ mockStream := &mockAudioStream{}
504+ mockFactory := &mockAudioFactory{stream: mockStream}
505+
506+ wv := &WebView{
507+ ctx: ctx,
508+ audioOutputFactory: mockFactory,
509+ }
510+
511+ handlers := &handlerSet{wv: wv}
512+
513+ params := &purecef.AudioParameters{
514+ SampleRate: 48000,
515+ ChannelLayout: purecef.ChannelLayoutStereo,
516+ FramesPerBuffer: 1024,
517+ }
518+
519+ // Third arg is channels=2, NOT framesPerBuffer.
520+ handlers.OnAudioStreamStarted(nil, params, 2)
521+
522+ require.NotNil(t, mockFactory.lastFormat)
523+ assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate)
524+ assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount, "channel count should be 2 (from callback arg)")
525+ assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")
526+}
527+
528+// ============================================================================
529+// Race-window tests (write/close interleave)
530+// ============================================================================
531+
532+// TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot
533+// interleave between the stream snapshot and Write. If the lock is NOT held
534+// across Write, the goroutine calling closeAudioStream will Close the stream
535+// while Write is in-flight, producing a write-after-close.
536+func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) {
537+ ctx := context.Background()
538+
539+ // racyStream blocks inside Write until we signal, giving closeAudioStream
540+ // a window to race if the mutex isn't held.
541+ stream := &racyMockAudioStream{
542+ writeCh: make(chan struct{}),
543+ doneCh: make(chan struct{}),
544+ }
545+
546+ wv := &WebView{
547+ ctx: ctx,
548+ activeAudioStream: stream,
549+ }
550+ handlers := &handlerSet{wv: wv}
551+
552+ data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
553+
554+ // Start a packet write — it will block inside Write.
555+ go handlers.OnAudioStreamPacket(nil, data, 2, 0)
556+
557+ // Wait for Write to be entered.
558+ <-stream.writeCh
559+
560+ // Now try to close. If the implementation holds the lock across Write,
561+ // this will block until Write returns. If not, Close races with Write.
562+ closeDone := make(chan struct{})
563+ go func() {
564+ wv.closeAudioStream()
565+ close(closeDone)
566+ }()
567+
568+ // Close should NOT have completed yet because Write holds the lock.
569+ // Give the goroutine time to acquire the lock if it can.
570+ time.Sleep(50 * time.Millisecond)
571+ select {
572+ case <-closeDone:
573+ t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write")
574+ default:
575+ // expected: closeAudioStream is blocked
576+ }
577+
578+ // Unblock Write.
579+ close(stream.doneCh)
580+
581+ // Now closeAudioStream should complete.
582+ <-closeDone
583+
584+ assert.True(t, stream.closeCalled)
585+ assert.Nil(t, wv.activeAudioStream)
586+}
587+
588+// TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a
589+// stream is closed between two OnAudioStreamPacket calls, the second call
590+// sees nil and silently drops the packet.
591+func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) {
592+ ctx := context.Background()
593+ stream := &mockAudioStream{}
594+
595+ wv := &WebView{
596+ ctx: ctx,
597+ activeAudioStream: stream,
598+ }
599+ handlers := &handlerSet{wv: wv}
600+ data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
601+
602+ // First packet succeeds.
603+ handlers.OnAudioStreamPacket(nil, data, 2, 0)
604+ assert.True(t, stream.writeCalled)
605+
606+ // Close the stream.
607+ wv.closeAudioStream()
608+
609+ // Second packet should be silently dropped.
610+ assert.NotPanics(t, func() {
611+ handlers.OnAudioStreamPacket(nil, data, 2, 1)
612+ })
613+}
614+
615+// ============================================================================
616+// Mock Factories
617+// ============================================================================
618+
619+// mockAudioFactory is a test double for port.AudioOutputFactory
620+type mockAudioFactory struct {
621+ stream port.AudioOutputStream
622+ err error
623+ lastFormat *port.AudioStreamFormat
624+ lastCtx context.Context
625+}
626+
627+func (m *mockAudioFactory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
628+ m.lastCtx = ctx
629+ m.lastFormat = &format
630+ return m.stream, m.err
631+}
632+
633+// Verify mock implements the interface
634+var _ port.AudioOutputFactory = (*mockAudioFactory)(nil)
A internal/infrastructure/cef/webview_handlers.go +886 -0
@@ -0,0 +1,886 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/infrastructure/transcoder"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Console message markers used by injected JavaScript for log filtering.
15+const (
16+ consoleMarkerVideoDiag = "[VIDEO-DIAG]"
17+ consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
18+)
19+
20+// handlerSet implements all CEF handler interfaces and dispatches events to the
21+// owning WebView. A single struct is used so that the Client's Get*Handler
22+// methods can return the same receiver, avoiding extra allocations.
23+type handlerSet struct {
24+ wv *WebView
25+ enableContextMenuHandler bool
26+ transcodingHandler purecef.ResourceRequestHandler
27+}
28+
29+// Compile-time interface checks.
30+var (
31+ _ purecef.SafeClient = (*handlerSet)(nil)
32+ _ purecef.RenderHandler = (*handlerSet)(nil)
33+ _ purecef.DisplayHandler = (*handlerSet)(nil)
34+ _ purecef.LoadHandler = (*handlerSet)(nil)
35+ _ purecef.SafeLifeSpanHandler = (*handlerSet)(nil)
36+ _ purecef.RequestHandler = (*handlerSet)(nil)
37+ _ purecef.AudioHandler = (*handlerSet)(nil)
38+ _ purecef.ContextMenuHandler = (*handlerSet)(nil)
39+ _ purecef.FindHandler = (*handlerSet)(nil)
40+)
41+
42+// ===========================================================================
43+// Client
44+// ===========================================================================
45+
46+func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
47+ // Always return the audio handler — without it, CEF has no audio output
48+ // path and refuses to start media decoding, breaking all video playback.
49+ // The handler accepts audio but discards packets (no-op) until a real
50+ // audio backend (PipeWire/PulseAudio) is wired up.
51+ return h
52+}
53+func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
54+func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {
55+ if h.enableContextMenuHandler {
56+ return h
57+ }
58+ return nil
59+}
60+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
61+func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
62+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
63+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
64+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
65+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
66+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
67+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
68+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
69+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
70+func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
71+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
72+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
73+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
74+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
75+
76+func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {
77+ return 0
78+}
79+
80+// ===========================================================================
81+// RenderHandler (17 methods)
82+// ===========================================================================
83+
84+func (h *handlerSet) GetAccessibilityHandler() purecef.AccessibilityHandler { return nil }
85+
86+func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
87+
88+// GetViewRect fills the rect struct with the pipeline dimensions.
89+func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
90+ if rect == nil {
91+ return
92+ }
93+ callSeq := h.wv.pipeline.nextViewRectSeq()
94+ w, ht, s := h.wv.pipeline.viewRectSize()
95+
96+ // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
97+ // in device pixels. The render pipeline tracks device pixels, so convert
98+ // back to DIP before answering GetViewRect/GetScreenInfo.
99+ w /= s
100+ ht /= s
101+
102+ // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area has
103+ // not been realized yet.
104+ if w <= 0 {
105+ w = 1
106+ }
107+ if ht <= 0 {
108+ ht = 1
109+ }
110+
111+ rect.X = 0
112+ rect.Y = 0
113+ rect.Width = w
114+ rect.Height = ht
115+ if h.wv != nil && h.wv.ctx != nil {
116+ logging.FromContext(h.wv.ctx).Debug().
117+ Uint64("view_rect_seq", callSeq).
118+ Int32("width", rect.Width).
119+ Int32("height", rect.Height).
120+ Int32("scale", s).
121+ Msg("cef: GetViewRect")
122+ }
123+}
124+
125+func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 {
126+ return 0
127+}
128+
129+func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) int32 {
130+ if info == nil {
131+ return 0
132+ }
133+ callSeq := h.wv.pipeline.nextScreenInfoSeq()
134+ w, ht, s := h.wv.pipeline.viewRectSize()
135+ w /= s
136+ ht /= s
137+ if w <= 0 {
138+ w = 1
139+ }
140+ if ht <= 0 {
141+ ht = 1
142+ }
143+
144+ const (
145+ screenDepth = 24
146+ screenDepthPerComponent = 8
147+ )
148+ r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht}
149+ si := purecef.NewScreenInfo()
150+ si.DeviceScaleFactor = float32(s)
151+ si.Depth = screenDepth
152+ si.DepthPerComponent = screenDepthPerComponent
153+ si.Rect = r
154+ si.AvailableRect = r
155+ *info = si
156+ if h.wv != nil && h.wv.ctx != nil {
157+ logging.FromContext(h.wv.ctx).Debug().
158+ Uint64("screen_info_seq", callSeq).
159+ Int32("width", w).
160+ Int32("height", ht).
161+ Int32("scale", s).
162+ Msg("cef: GetScreenInfo")
163+ }
164+ return 1
165+}
166+
167+func (h *handlerSet) OnPopupShow(_ purecef.Browser, show int32) {
168+ if h.wv == nil || h.wv.pipeline == nil {
169+ return
170+ }
171+ h.wv.runOnGTK(func() {
172+ h.wv.pipeline.setPopupVisible(show != 0)
173+ })
174+}
175+
176+func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
177+ if h.wv == nil || h.wv.pipeline == nil || popupRect == nil {
178+ return
179+ }
180+ popup := rect{
181+ X: popupRect.X,
182+ Y: popupRect.Y,
183+ Width: popupRect.Width,
184+ Height: popupRect.Height,
185+ }
186+ h.wv.runOnGTK(func() {
187+ h.wv.pipeline.setPopupRect(popup)
188+ })
189+}
190+
191+// OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
192+// to the render pipeline for GPU upload. Main-view paints are copied directly
193+// into the persistent staging buffer on the CEF UI thread; only the GTK
194+// QueueRender hop remains. Popups still copy their transient buffer before
195+// crossing threads because they are small and infrequent.
196+func (h *handlerSet) OnPaint(
197+ _ purecef.Browser, elementType purecef.PaintElementType,
198+ dirtyRects []purecef.Rect, buffer []byte, width, height int32,
199+) {
200+ if len(buffer) == 0 || width <= 0 || height <= 0 {
201+ return
202+ }
203+ paintSeq := h.wv.pipeline.nextPaintSeq()
204+ if h.wv != nil && h.wv.ctx != nil {
205+ logging.FromContext(h.wv.ctx).Trace().
206+ Uint64("paint_seq", paintSeq).
207+ Int32("width", width).
208+ Int32("height", height).
209+ Int("dirty_rect_count", len(dirtyRects)).
210+ Int("buffer_len", len(buffer)).
211+ Msg("cef: OnPaint begin")
212+ }
213+ rects := make([]rect, len(dirtyRects))
214+ for i, dr := range dirtyRects {
215+ rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
216+ }
217+
218+ if elementType == purecef.PaintElementTypePetPopup {
219+ pixels := make([]byte, len(buffer))
220+ copy(pixels, buffer)
221+ h.wv.runOnGTK(func() {
222+ h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
223+ })
224+ if h.wv != nil && h.wv.ctx != nil {
225+ logging.FromContext(h.wv.ctx).Trace().
226+ Uint64("paint_seq", paintSeq).
227+ Msg("cef: OnPaint queued popup to GTK")
228+ }
229+ return
230+ }
231+
232+ h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
233+ h.wv.runOnGTK(func() {
234+ h.wv.pipeline.queuePaintRender()
235+ })
236+ if h.wv != nil && h.wv.ctx != nil {
237+ logging.FromContext(h.wv.ctx).Trace().
238+ Uint64("paint_seq", paintSeq).
239+ Msg("cef: OnPaint queued to GTK")
240+ }
241+}
242+
243+func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {
244+ count := h.wv.pipeline.recordAcceleratedPaint()
245+ if count <= 5 || count%100 == 0 {
246+ logging.FromContext(h.wv.ctx).Info().
247+ Uint64("count", count).
248+ Msg("cef: OnAcceleratedPaint")
249+ }
250+}
251+
252+func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalAlignment, _ *purecef.Size) {
253+}
254+
255+func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}
256+
257+func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 {
258+ return 0
259+}
260+
261+func (h *handlerSet) UpdateDragCursor(_ purecef.Browser, _ purecef.DragOperationsMask) {}
262+
263+func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {}
264+
265+func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {
266+}
267+
268+func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {}
269+
270+func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}
271+
272+// ===========================================================================
273+// DisplayHandler (13 methods)
274+// ===========================================================================
275+
276+// OnAddressChange updates the cached URI when the main frame navigates.
277+func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
278+ if frame != nil && frame.IsMain() {
279+ h.wv.updateURI(url)
280+ }
281+}
282+
283+// OnTitleChange updates the cached title.
284+func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
285+ h.wv.updateTitle(title)
286+}
287+
288+func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ uintptr) {}
289+
290+// OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
291+func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
292+ entering := fullscreen != 0
293+ h.wv.fullscreen.Store(entering)
294+
295+ h.wv.mu.RLock()
296+ cb := h.wv.callbacks
297+ h.wv.mu.RUnlock()
298+ if cb == nil {
299+ return
300+ }
301+ if entering && cb.OnEnterFullscreen != nil {
302+ h.wv.runOnGTK(func() {
303+ cb.OnEnterFullscreen()
304+ })
305+ }
306+ if !entering && cb.OnLeaveFullscreen != nil {
307+ h.wv.runOnGTK(func() {
308+ cb.OnLeaveFullscreen()
309+ })
310+ }
311+}
312+
313+func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
314+
315+// OnStatusMessage fires the OnLinkHover callback with the status text
316+// and caches the hover URI for middle-click interception.
317+func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
318+ h.wv.updateHoverURI(value)
319+
320+ h.wv.mu.RLock()
321+ cb := h.wv.callbacks
322+ h.wv.mu.RUnlock()
323+ if cb != nil && cb.OnLinkHover != nil {
324+ h.wv.runOnGTK(func() {
325+ cb.OnLinkHover(value)
326+ })
327+ }
328+}
329+
330+func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 {
331+ if h.wv != nil && h.wv.ctx != nil &&
332+ (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) {
333+ log := logging.FromContext(h.wv.ctx).With().
334+ Str("component", "cef-console").
335+ Str("source", source).
336+ Int32("line", line).
337+ Logger()
338+ switch level {
339+ case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal:
340+ log.Error().Str("message", message).Msg("cef: console message")
341+ case purecef.LogSeverityLogseverityWarning:
342+ log.Warn().Str("message", message).Msg("cef: console message")
343+ default:
344+ log.Info().Str("message", message).Msg("cef: console message")
345+ }
346+ }
347+ return 0
348+}
349+
350+func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { return 0 }
351+
352+// OnLoadingProgressChange updates the cached progress value.
353+func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
354+ h.wv.updateProgress(progress)
355+}
356+
357+func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {
358+ name := cefCursorToGDKName(cursorType)
359+ h.wv.runOnGTK(func() {
360+ h.wv.pipeline.glArea.SetCursorFromName(&name)
361+ })
362+ return 1 // handled
363+}
364+
365+func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
366+
367+func (h *handlerSet) OnContentsBoundsChange(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
368+
369+func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
370+
371+// ===========================================================================
372+// LoadHandler (4 methods)
373+// ===========================================================================
374+
375+// OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
376+func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
377+ loading := isloading != 0
378+ h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
379+
380+ h.wv.mu.RLock()
381+ cb := h.wv.callbacks
382+ h.wv.mu.RUnlock()
383+ if cb != nil && cb.OnLoadChanged != nil {
384+ if loading {
385+ h.wv.runOnGTK(func() {
386+ cb.OnLoadChanged(port.LoadStarted)
387+ })
388+ } else {
389+ h.wv.runOnGTK(func() {
390+ cb.OnLoadChanged(port.LoadFinished)
391+ })
392+ }
393+ }
394+}
395+
396+// OnLoadStart fires LoadCommitted for main frame navigations.
397+// In CEF this callback runs after navigation commit, so it is the closest
398+// equivalent to WebKit's LoadCommitted event.
399+func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
400+ if frame == nil || !frame.IsMain() {
401+ return
402+ }
403+ h.wv.updateURI(frame.GetURL())
404+ h.wv.mu.RLock()
405+ cb := h.wv.callbacks
406+ h.wv.mu.RUnlock()
407+ if cb != nil && cb.OnLoadChanged != nil {
408+ h.wv.runOnGTK(func() {
409+ cb.OnLoadChanged(port.LoadCommitted)
410+ })
411+ }
412+}
413+
414+// OnLoadEnd resets crash count and injects content scripts for the main frame.
415+func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
416+ log := logging.FromContext(h.wv.ctx)
417+ log.Debug().
418+ Bool("frame_nil", frame == nil).
419+ Bool("is_main", frame != nil && frame.IsMain()).
420+ Int32("http_status", httpStatusCode).
421+ Msg("cef: OnLoadEnd")
422+ if frame == nil || !frame.IsMain() {
423+ return
424+ }
425+ // Successful load — reset the consecutive crash counter.
426+ h.wv.crashCount.Store(0)
427+
428+ // Inject scripts and styles after page load.
429+ // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
430+ // and JavaScript injection requires the main thread.
431+ if h.wv.engine != nil && h.wv.engine.contentInj != nil {
432+ h.wv.runOnGTK(func() {
433+ h.wv.engine.contentInj.onLoadEnd(h.wv)
434+ })
435+ }
436+
437+ // NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
438+ // here. OnLoadEnd is a per-frame event, and during cross-site navigations
439+ // (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
440+ // page finishes loading. Dispatching LoadFinished here caused the progress
441+ // bar to hide prematurely while the new page was still loading.
442+ //
443+ // OnLoadingStateChange(isLoading=false) is the correct browser-level signal
444+ // for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
445+ // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
446+}
447+
448+// OnLoadError is a no-op.
449+func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
450+}
451+
452+// ===========================================================================
453+// LifeSpanHandler (6 methods)
454+// ===========================================================================
455+
456+// OnBeforePopup intercepts popup requests (target="_blank", window.open).
457+// CEF OSR cannot create popup windows, so we fire the OnCreate callback
458+// to let the coordinator open the link in a new stacked pane.
459+func (h *handlerSet) OnBeforePopup(
460+ _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
461+ _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
462+ _ *purecef.WindowInfo, _ *purecef.Client, _ *purecef.BrowserSettings,
463+ _ *purecef.DictionaryValue, _ *bool,
464+) bool {
465+ if targetURL == "" {
466+ return true
467+ }
468+
469+ h.wv.mu.RLock()
470+ cb := h.wv.callbacks
471+ h.wv.mu.RUnlock()
472+
473+ if cb != nil && cb.OnCreate != nil {
474+ req := port.PopupRequest{
475+ TargetURI: targetURL,
476+ FrameName: targetFrameName,
477+ IsUserGesture: userGesture != 0,
478+ ParentViewID: h.wv.id,
479+ }
480+ h.wv.runOnGTK(func() {
481+ cb.OnCreate(req)
482+ })
483+ }
484+
485+ return true // always block CEF popup; the coordinator handles the new pane
486+}
487+
488+func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {}
489+
490+func (h *handlerSet) OnBeforeDevToolsPopup(
491+ _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client,
492+ _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
493+) {
494+}
495+
496+// OnAfterCreated stores the browser and host references and enables input.
497+func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
498+ log := logging.FromContext(h.wv.ctx)
499+ if browser == nil {
500+ log.Warn().Msg("cef: OnAfterCreated called with nil browser")
501+ return
502+ }
503+ browserID := browser.GetIdentifier()
504+ log.Debug().
505+ Int32("browser_id", browserID).
506+ Bool("context_menu_handler_enabled", h.enableContextMenuHandler).
507+ Msg("cef: OnAfterCreated")
508+ if h.wv.engine != nil {
509+ h.wv.engine.recordBrowserAfterCreated(browser)
510+ h.wv.engine.registerWebView(h.wv)
511+ }
512+ host := browser.GetHost()
513+
514+ h.wv.mu.Lock()
515+ h.wv.browser = browser
516+ h.wv.host = host
517+ uri := h.wv.pendingURI
518+ h.wv.pendingURI = ""
519+ h.wv.input.setHost(host)
520+ if h.wv.findCtrl != nil {
521+ h.wv.findCtrl.setHost(host)
522+ }
523+
524+ // Mark browser as visible — CEF OSR starts in hidden state and suppresses
525+ // UI elements like the text caret until explicitly told the browser is shown.
526+ host.WasHidden(0)
527+
528+ // If the GLArea already has GTK focus (cold start race), tell CEF now.
529+ // The focus-enter event fired before the browser existed, so SetFocus
530+ // was never called and the caret won't blink until the user clicks.
531+ if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() {
532+ host.SetFocus(1)
533+ }
534+
535+ // Replay any navigation that was requested before the browser existed.
536+ if uri != "" {
537+ if frame := browser.GetMainFrame(); frame != nil {
538+ frame.LoadURL(uri)
539+ }
540+ }
541+ h.wv.mu.Unlock()
542+
543+ h.wv.scheduleStartBeginFrameLoop()
544+}
545+
546+// DoClose returns false to allow the default close behavior.
547+func (h *handlerSet) DoClose(_ purecef.Browser) bool {
548+ return false
549+}
550+
551+// OnBeforeClose fires the OnClose callback.
552+func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
553+ if h.wv.engine != nil {
554+ h.wv.engine.unregisterWebView(h.wv)
555+ }
556+ h.wv.mu.Lock()
557+ h.wv.browser = nil
558+ h.wv.host = nil
559+ h.wv.input.setHost(nil)
560+ if h.wv.findCtrl != nil {
561+ h.wv.findCtrl.setHost(nil)
562+ }
563+ h.wv.mu.Unlock()
564+ h.wv.scheduleStopBeginFrameLoop()
565+
566+ h.wv.mu.RLock()
567+ cb := h.wv.callbacks
568+ h.wv.mu.RUnlock()
569+ if cb != nil && cb.OnClose != nil {
570+ h.wv.runOnGTK(func() {
571+ cb.OnClose()
572+ })
573+ }
574+}
575+
576+// ===========================================================================
577+// RequestHandler (11 methods)
578+// ===========================================================================
579+
580+func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _, _ int32) bool {
581+ return false
582+}
583+
584+func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ string, _ purecef.WindowOpenDisposition, _ int32) int32 {
585+ return 0
586+}
587+
588+func (h *handlerSet) GetResourceRequestHandler(
589+ _ purecef.Browser, _ purecef.Frame, request purecef.Request,
590+ _, _ int32, _ string, disableDefaultHandling *int32,
591+) purecef.ResourceRequestHandler {
592+ if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) {
593+ *disableDefaultHandling = 1
594+ if h.wv != nil && h.wv.ctx != nil {
595+ logging.FromContext(h.wv.ctx).Info().
596+ Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)).
597+ Msg("cef: disabled default handling for eager transcode candidate")
598+ }
599+ }
600+ if h.transcodingHandler != nil {
601+ return h.transcodingHandler
602+ }
603+ return nil
604+}
605+
606+func (h *handlerSet) GetAuthCredentials(
607+ _ purecef.Browser, _ string, _ int32, _ string, _ int32,
608+ _, _ string, _ purecef.AuthCallback,
609+) int32 {
610+ return 0
611+}
612+
613+func (h *handlerSet) OnCertificateError(_ purecef.Browser, _ purecef.Errorcode, _ string, _ purecef.Sslinfo, _ purecef.Callback) int32 {
614+ return 0
615+}
616+
617+func (h *handlerSet) OnSelectClientCertificate(
618+ _ purecef.Browser, _ int32, _ string, _ int32,
619+ _ []purecef.X509Certificate, _ purecef.SelectClientCertificateCallback,
620+) int32 {
621+ return 0
622+}
623+
624+func (h *handlerSet) OnRenderViewReady(_ purecef.Browser) {}
625+
626+func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.UnresponsiveProcessCallback) int32 {
627+ return 0
628+}
629+
630+func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
631+
632+// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
633+// forward the event before suppressing further notifications. This prevents
634+// infinite crash → redirect → crash loops.
635+const maxConsecutiveCrashes = 3
636+
637+// OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
638+func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
639+ if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
640+ return // suppress to break the loop
641+ }
642+
643+ var reason port.WebProcessTerminationReason
644+ var label string
645+ switch status {
646+ case purecef.TerminationStatusTsAbnormalTermination:
647+ reason = port.WebProcessTerminationCrashed
648+ label = "abnormal termination"
649+ case purecef.TerminationStatusTsProcessWasKilled:
650+ reason = port.WebProcessTerminationByAPI
651+ label = "killed"
652+ case purecef.TerminationStatusTsProcessCrashed:
653+ reason = port.WebProcessTerminationCrashed
654+ label = "crashed"
655+ case purecef.TerminationStatusTsProcessOom:
656+ reason = port.WebProcessTerminationExceededMemory
657+ label = "out of memory"
658+ default:
659+ reason = port.WebProcessTerminationUnknown
660+ label = "unknown"
661+ }
662+
663+ h.wv.mu.RLock()
664+ cb := h.wv.callbacks
665+ uri := h.wv.uri
666+ h.wv.mu.RUnlock()
667+ if cb != nil && cb.OnWebProcessTerminated != nil {
668+ h.wv.runOnGTK(func() {
669+ cb.OnWebProcessTerminated(reason, label, uri)
670+ })
671+ }
672+}
673+
674+func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
675+
676+// ===========================================================================
677+// AudioHandler (5 methods)
678+// ===========================================================================
679+
680+func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
681+ return 1 // proceed with defaults
682+}
683+
684+// OnAudioStreamStarted handles the start of an audio stream.
685+// The third callback argument is the channel count (not frames-per-buffer);
686+// frames-per-buffer comes from params.FramesPerBuffer.
687+func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) {
688+ // Validate params and factory first
689+ if params == nil || h.wv.audioOutputFactory == nil {
690+ return
691+ }
692+
693+ // Build format from CEF parameters
694+ format := h.buildAudioStreamFormat(params, channels)
695+
696+ if h.wv.ctx != nil {
697+ logging.FromContext(h.wv.ctx).Info().
698+ Int("sample_rate", format.SampleRate).
699+ Int("channels", format.ChannelCount).
700+ Int("frames_per_buffer", format.FramesPerBuffer).
701+ Int32("channel_layout", params.ChannelLayout).
702+ Msg("cef: OnAudioStreamStarted")
703+ }
704+
705+ // Reset packet counters for the new stream
706+ h.wv.audioPacketCount.Store(0)
707+ h.wv.audioWriteCount.Store(0)
708+
709+ // Close any existing stream first
710+ h.wv.closeAudioStream()
711+
712+ // Create new stream
713+ ctx := h.wv.ctx
714+ if ctx == nil {
715+ ctx = context.Background()
716+ }
717+
718+ stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
719+ if err != nil {
720+ // Log error but don't panic - audio is non-critical.
721+ // Do NOT set audioPlaying — the stream was never created.
722+ if h.wv.ctx != nil {
723+ logging.FromContext(h.wv.ctx).Warn().
724+ Err(err).
725+ Int("sample_rate", format.SampleRate).
726+ Int("channels", format.ChannelCount).
727+ Msg("cef: failed to create audio output stream")
728+ }
729+ return
730+ }
731+
732+ // Set playing state only after stream creation succeeds
733+ h.wv.setAudioPlaying(true)
734+
735+ if h.wv.ctx != nil {
736+ logging.FromContext(h.wv.ctx).Info().
737+ Int("sample_rate", format.SampleRate).
738+ Int("channels", format.ChannelCount).
739+ Int("frames_per_buffer", format.FramesPerBuffer).
740+ Msg("cef: audio output stream created successfully")
741+ }
742+
743+ // Store the new stream
744+ h.wv.audioStreamMu.Lock()
745+ h.wv.activeAudioStream = stream
746+ h.wv.audioStreamMu.Unlock()
747+}
748+
749+// buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
750+// channels is the authoritative channel count from the callback; params.FramesPerBuffer
751+// supplies the buffer size.
752+func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat {
753+ return port.AudioStreamFormat{
754+ SampleRate: int(params.SampleRate),
755+ ChannelCount: int(channels),
756+ FramesPerBuffer: int(params.FramesPerBuffer),
757+ }
758+}
759+
760+// OnAudioStreamPacket receives audio packets and forwards them to the output stream.
761+// The mutex is held across both the stream snapshot and Write to prevent
762+// closeAudioStream from closing the stream mid-write.
763+func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) {
764+ if len(data) == 0 || frames <= 0 {
765+ return
766+ }
767+
768+ // Copy the data before acquiring the stream lock because CEF can reuse the
769+ // buffer as soon as this callback returns.
770+ copiedData := make([][]float32, len(data))
771+ for i, channel := range data {
772+ if len(channel) < int(frames) {
773+ continue
774+ }
775+ copiedData[i] = make([]float32, frames)
776+ copy(copiedData[i], channel[:frames])
777+ }
778+
779+ // Hold the lock across the stream read and Write so closeAudioStream
780+ // cannot close the stream between snapshot and write.
781+ h.wv.audioStreamMu.Lock()
782+ stream := h.wv.activeAudioStream
783+ if stream == nil {
784+ h.wv.audioStreamMu.Unlock()
785+ return
786+ }
787+
788+ pktNum := h.wv.audioPacketCount.Add(1)
789+ if pktNum == 1 && h.wv.ctx != nil {
790+ logging.FromContext(h.wv.ctx).Info().
791+ Int("channels", len(data)).
792+ Int32("frames", frames).
793+ Int64("pts", pts).
794+ Msg("cef: first audio packet received")
795+ }
796+
797+ err := stream.Write(copiedData)
798+ h.wv.audioStreamMu.Unlock()
799+
800+ if err != nil {
801+ if h.wv.ctx != nil {
802+ logging.FromContext(h.wv.ctx).Debug().
803+ Err(err).
804+ Uint64("packets_received", pktNum).
805+ Msg("cef: audio stream write failed")
806+ }
807+ return
808+ }
809+ h.wv.audioWriteCount.Add(1)
810+}
811+
812+// OnAudioStreamStopped handles stream stop.
813+func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
814+ if h.wv.ctx != nil {
815+ logging.FromContext(h.wv.ctx).Info().
816+ Uint64("packets_received", h.wv.audioPacketCount.Load()).
817+ Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
818+ Msg("cef: OnAudioStreamStopped")
819+ }
820+ h.wv.setAudioPlaying(false)
821+ h.wv.closeAudioStream()
822+}
823+
824+// OnAudioStreamError handles stream errors.
825+func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
826+ if h.wv.ctx != nil {
827+ logging.FromContext(h.wv.ctx).Warn().
828+ Str("error", message).
829+ Uint64("packets_received", h.wv.audioPacketCount.Load()).
830+ Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
831+ Msg("cef: audio stream error")
832+ }
833+ h.wv.setAudioPlaying(false)
834+ h.wv.closeAudioStream()
835+}
836+
837+// ===========================================================================
838+// FindHandler (1 method)
839+// ===========================================================================
840+
841+// OnFindResult dispatches CEF find results to the WebView's FindController.
842+func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) {
843+ if h.wv.findCtrl != nil {
844+ h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
845+ }
846+}
847+
848+// cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
849+var cefCursorNames = map[purecef.CursorType]string{
850+ purecef.CursorTypeCtPointer: "default",
851+ purecef.CursorTypeCtCross: "crosshair",
852+ purecef.CursorTypeCtHand: "pointer",
853+ purecef.CursorTypeCtIbeam: "text",
854+ purecef.CursorTypeCtWait: "wait",
855+ purecef.CursorTypeCtHelp: "help",
856+ purecef.CursorTypeCtEastresize: "e-resize",
857+ purecef.CursorTypeCtNorthresize: "n-resize",
858+ purecef.CursorTypeCtNortheastresize: "ne-resize",
859+ purecef.CursorTypeCtNorthwestresize: "nw-resize",
860+ purecef.CursorTypeCtSouthresize: "s-resize",
861+ purecef.CursorTypeCtSoutheastresize: "se-resize",
862+ purecef.CursorTypeCtSouthwestresize: "sw-resize",
863+ purecef.CursorTypeCtWestresize: "w-resize",
864+ purecef.CursorTypeCtNorthsouthresize: "ns-resize",
865+ purecef.CursorTypeCtEastwestresize: "ew-resize",
866+ purecef.CursorTypeCtNortheastsouthwestresize: "nesw-resize",
867+ purecef.CursorTypeCtNorthwestsoutheastresize: "nwse-resize",
868+ purecef.CursorTypeCtColumnresize: "col-resize",
869+ purecef.CursorTypeCtRowresize: "row-resize",
870+ purecef.CursorTypeCtMove: "move",
871+ purecef.CursorTypeCtProgress: "progress",
872+ purecef.CursorTypeCtNodrop: "no-drop",
873+ purecef.CursorTypeCtNotallowed: "not-allowed",
874+ purecef.CursorTypeCtGrab: "grab",
875+ purecef.CursorTypeCtGrabbing: "grabbing",
876+ purecef.CursorTypeCtZoomin: "zoom-in",
877+ purecef.CursorTypeCtZoomout: "zoom-out",
878+}
879+
880+// cefCursorToGDKName maps a CEF cursor type to a GDK/CSS cursor name.
881+func cefCursorToGDKName(ct purecef.CursorType) string {
882+ if name, ok := cefCursorNames[ct]; ok {
883+ return name
884+ }
885+ return "default"
886+}
A internal/infrastructure/cef/webview_handlers_test.go +139 -0
@@ -0,0 +1,139 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+func newTestPipeline(w, h, s int32) *renderPipeline {
15+ rp := &renderPipeline{scale: s}
16+ rp.widthAtomic.Store(w)
17+ rp.heightAtomic.Store(h)
18+ return rp
19+}
20+
21+type stubFrame struct {
22+ main bool
23+ url string
24+}
25+
26+func (f stubFrame) IsValid() bool { return true }
27+func (f stubFrame) Undo() {}
28+func (f stubFrame) Redo() {}
29+func (f stubFrame) Cut() {}
30+func (f stubFrame) Copy() {}
31+func (f stubFrame) Paste() {}
32+func (f stubFrame) PasteAndMatchStyle() {}
33+func (f stubFrame) Del() {}
34+func (f stubFrame) SelectAll() {}
35+func (f stubFrame) ViewSource() {}
36+func (f stubFrame) GetSource(purecef.StringVisitor) {}
37+func (f stubFrame) GetText(purecef.StringVisitor) {}
38+func (f stubFrame) LoadRequest(purecef.Request) {}
39+func (f stubFrame) LoadURL(string) {}
40+func (f stubFrame) ExecuteJavaScript(string, string, int32) {}
41+func (f stubFrame) IsMain() bool { return f.main }
42+func (f stubFrame) IsFocused() bool { return false }
43+func (f stubFrame) GetName() string { return "" }
44+func (f stubFrame) GetIdentifier() string { return "" }
45+func (f stubFrame) GetParent() purecef.Frame { return nil }
46+func (f stubFrame) GetURL() string { return f.url }
47+func (f stubFrame) GetBrowser() purecef.Browser { return nil }
48+func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
49+func (f stubFrame) VisitDom(purecef.Domvisitor) {}
50+func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {}
51+func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
52+ return nil
53+}
54+
55+func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
56+ wv := &WebView{ctx: context.Background()}
57+ var gotEvents []port.LoadEvent
58+ wv.SetCallbacks(&port.WebViewCallbacks{
59+ OnLoadChanged: func(event port.LoadEvent) {
60+ gotEvents = append(gotEvents, event)
61+ },
62+ })
63+
64+ h := &handlerSet{wv: wv}
65+ h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
66+
67+ require.Len(t, gotEvents, 1)
68+ require.Equal(t, port.LoadCommitted, gotEvents[0])
69+ require.Equal(t, "https://google.com", wv.URI())
70+}
71+
72+func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
73+ wv := &WebView{ctx: context.Background()}
74+ var gotEvents []port.LoadEvent
75+ var gotProgress []float64
76+ wv.SetCallbacks(&port.WebViewCallbacks{
77+ OnLoadChanged: func(event port.LoadEvent) {
78+ gotEvents = append(gotEvents, event)
79+ },
80+ OnProgressChanged: func(progress float64) {
81+ gotProgress = append(gotProgress, progress)
82+ },
83+ })
84+
85+ h := &handlerSet{wv: wv}
86+ h.OnLoadEnd(nil, stubFrame{main: true, url: "https://reddit.com"}, 200)
87+
88+ assert.Empty(t, gotEvents)
89+ assert.Empty(t, gotProgress)
90+}
91+
92+func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
93+ rect := &purecef.Rect{}
94+ h := &handlerSet{
95+ wv: &WebView{
96+ ctx: context.Background(),
97+ pipeline: newTestPipeline(800, 600, 2),
98+ },
99+ }
100+
101+ h.GetViewRect(nil, rect)
102+
103+ require.Equal(t, int32(0), rect.X)
104+ require.Equal(t, int32(0), rect.Y)
105+ require.Equal(t, int32(400), rect.Width)
106+ require.Equal(t, int32(300), rect.Height)
107+}
108+
109+func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
110+ si := purecef.NewScreenInfo()
111+ info := &si
112+ h := &handlerSet{
113+ wv: &WebView{
114+ ctx: context.Background(),
115+ pipeline: newTestPipeline(1500, 900, 3),
116+ },
117+ }
118+
119+ ok := h.GetScreenInfo(nil, info)
120+
121+ require.Equal(t, int32(1), ok)
122+ require.InEpsilon(t, float32(3), info.DeviceScaleFactor, 0.001)
123+ require.Equal(t, int32(500), info.Rect.Width)
124+ require.Equal(t, int32(300), info.Rect.Height)
125+ require.Equal(t, int32(500), info.AvailableRect.Width)
126+ require.Equal(t, int32(300), info.AvailableRect.Height)
127+}
128+
129+func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
130+ h := &handlerSet{}
131+
132+ // AudioHandler is always enabled (required for media decoding).
133+ require.Same(t, h, h.GetAudioHandler())
134+ require.Nil(t, h.GetContextMenuHandler())
135+
136+ h.enableContextMenuHandler = true
137+
138+ require.Same(t, h, h.GetContextMenuHandler())
139+}
A internal/infrastructure/cef/webview_zoom_test.go +19 -0
@@ -0,0 +1,19 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestZoomConversionsRoundTrip(t *testing.T) {
11+ factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}
12+ for _, factor := range factors {
13+ t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {
14+ level := cefZoomFromFactor(factor)
15+ got := factorFromCEFZoom(level)
16+ assert.InDelta(t, factor, got, 1e-9, "round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
17+ })
18+ }
19+}
M internal/infrastructure/config/defaults.go +18 -0
@@ -67,6 +67,13 @@ const (
67 // Performance defaults67 // Performance defaults
68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)68 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup69 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
70+ defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71+
72+ // Transcoding defaults
73+ defaultTranscodingEnabled = false
74+ defaultTranscodingHWAccel = "auto"
75+ defaultTranscodingMaxConcurrent = 3
76+ defaultTranscodingQuality = "medium"
70 77
71 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)78 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
72 defaultSkiaCPUPaintingThreads = 079 defaultSkiaCPUPaintingThreads = 0
@@ -151,6 +158,11 @@ func DefaultConfig() *Config {
151 // third-party cookie isolation more intelligently. Using Always + ITP158 // third-party cookie isolation more intelligently. Using Always + ITP
152 // matches Epiphany's model and avoids a misleading setting.159 // matches Epiphany's model and avoids a misleading setting.
153 CookiePolicy: CookiePolicyAlways,160 CookiePolicy: CookiePolicyAlways,
161+ CEF: CEFEngineConfig{
162+ WindowlessFrameRate: defaultCEFWindowlessFrameRate,
163+ LogFile: "",
164+ EnableAudioHandler: true,
165+ },
154 WebKit: WebKitEngineConfig{166 WebKit: WebKitEngineConfig{
155 ITPEnabled: true,167 ITPEnabled: true,
156 SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,168 SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
@@ -305,6 +317,12 @@ func DefaultConfig() *Config {
305 Downloads: DownloadsConfig{317 Downloads: DownloadsConfig{
306 Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads318 Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
307 },319 },
320+ Transcoding: TranscodingConfig{
321+ Enabled: defaultTranscodingEnabled,
322+ HWAccel: defaultTranscodingHWAccel,
323+ MaxConcurrent: defaultTranscodingMaxConcurrent,
324+ Quality: defaultTranscodingQuality,
325+ },
308 }326 }
309 }327 }
310 328
M internal/infrastructure/config/defaults_test.go +9 -0
@@ -11,11 +11,20 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
11 assert.Equal(t, "info", cfg.Logging.Level)11 assert.Equal(t, "info", cfg.Logging.Level)
12 assert.False(t, cfg.Logging.CaptureGTKLogs)12 assert.False(t, cfg.Logging.CaptureGTKLogs)
13 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)13 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
14+ assert.False(t, cfg.Transcoding.Enabled)
15+ assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
16+ assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
17+ assert.Equal(t, "medium", cfg.Transcoding.Quality)
14 18
15 // Engine defaults (replaces old Performance/Privacy sections)19 // Engine defaults (replaces old Performance/Privacy sections)
16 assert.Equal(t, "webkit", cfg.Engine.Type)20 assert.Equal(t, "webkit", cfg.Engine.Type)
17 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)21 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
18 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())
24+ assert.Empty(t, cfg.Engine.CEF.LogFile)
25+ assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
26+ assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
27+ assert.False(t, cfg.Engine.CEF.TraceHandlers)
19 assert.True(t, cfg.Engine.WebKit.ITPEnabled)28 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
20 29
21 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.30 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
M internal/infrastructure/config/engine.go +54 -0
@@ -1,5 +1,12 @@
1 package config1 package config
2 2
3+import "os"
4+
5+const (
6+ EngineTypeWebKit = "webkit"
7+ EngineTypeCEF = "cef"
8+)
9+
3 // EngineConfig holds engine selection and universal engine options.10 // EngineConfig holds engine selection and universal engine options.
4 type EngineConfig struct {11 type EngineConfig struct {
5 Type string `mapstructure:"type" toml:"type" yaml:"type"`12 Type string `mapstructure:"type" toml:"type" yaml:"type"`
@@ -8,6 +15,21 @@ type EngineConfig struct {
8 Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"`15 Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"`
9 CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"`16 CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"`
10 WebKit WebKitEngineConfig `mapstructure:"webkit" toml:"webkit" yaml:"webkit"`17 WebKit WebKitEngineConfig `mapstructure:"webkit" toml:"webkit" yaml:"webkit"`
18+ CEF CEFEngineConfig `mapstructure:"cef" toml:"cef" yaml:"cef"`
19+}
20+
21+// ResolveEngineType returns the effective engine type from config + env override.
22+// It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE
23+// to override the configured value for smoke testing.
24+func (e *EngineConfig) ResolveEngineType() string {
25+ engineType := e.Type
26+ if engineType == "" {
27+ engineType = EngineTypeWebKit
28+ }
29+ if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
30+ engineType = envEngine
31+ }
32+ return engineType
11 }33 }
12 34
13 // WebKitEngineConfig holds WebKit-specific engine options.35 // WebKitEngineConfig holds WebKit-specific engine options.
@@ -57,6 +79,38 @@ type WebKitEngineConfig struct {
57 NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags exceed lll limit79 NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags exceed lll limit
58 }80 }
59 81
82+// CEFEngineConfig holds CEF-specific engine options.
83+type CEFEngineConfig struct {
84+ // CEFDir is the path to the CEF framework directory containing
85+ // libcef.so and the Resources/locales subdirectories.
86+ CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
87+ // LogFile overrides the CEF internal log destination. When empty,
88+ // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log.
89+ LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
90+ // LogSeverity controls CEF's internal log verbosity.
91+ // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
92+ LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
93+ // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
94+ // Default: 60. Higher values increase CPU usage.
95+ WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
96+ // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
97+ EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
98+ // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge.
99+ EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit
100+ // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
101+ TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
102+}
103+
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.
107+func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
108+ if c.WindowlessFrameRate > 0 {
109+ return c.WindowlessFrameRate
110+ }
111+ return defaultCEFWindowlessFrameRate
112+}
113+
60 // PerformanceConfigFromEngine constructs a PerformanceConfig from the114 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
61 // new [engine] config section, for use with ResolvePerformanceProfile.115 // new [engine] config section, for use with ResolvePerformanceProfile.
62 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {116 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
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+}
M internal/infrastructure/config/loader.go +18 -0
@@ -414,6 +414,7 @@ func (m *Manager) setDefaults() {
414 m.setDebugDefaults(defaults)414 m.setDebugDefaults(defaults)
415 m.setAppearanceDefaults(defaults)415 m.setAppearanceDefaults(defaults)
416 m.setEngineDefaults(defaults)416 m.setEngineDefaults(defaults)
417+ m.setTranscodingDefaults(defaults)
417 m.setZoomAndScaleDefaults(defaults)418 m.setZoomAndScaleDefaults(defaults)
418 m.setWorkspaceDefaults(defaults)419 m.setWorkspaceDefaults(defaults)
419 m.setContentFilteringDefaults(defaults)420 m.setContentFilteringDefaults(defaults)
@@ -568,6 +569,15 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
568 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)569 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
569 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))570 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
570 571
572+ ce := e.CEF
573+ m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
574+ m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
575+ m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
576+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
577+ m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
578+ m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
579+ m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
580+
571 wk := e.WebKit581 wk := e.WebKit
572 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)582 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
573 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)583 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
@@ -597,6 +607,14 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
597 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)607 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
598 }608 }
599 609
610+func (m *Manager) setTranscodingDefaults(defaults *Config) {
611+ tc := defaults.Transcoding
612+ m.viper.SetDefault("transcoding.enabled", tc.Enabled)
613+ m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel)
614+ m.viper.SetDefault("transcoding.max_concurrent", tc.MaxConcurrent)
615+ m.viper.SetDefault("transcoding.quality", tc.Quality)
616+}
617+
600 // New returns a new default configuration instance.618 // New returns a new default configuration instance.
601 // This is a convenience function for getting default config without the full manager.619 // This is a convenience function for getting default config without the full manager.
602 func New() *Config {620 func New() *Config {
M internal/infrastructure/config/loader_test.go +40 -0
@@ -1,10 +1,13 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "testing"6 "testing"
5 7
6 "github.com/spf13/viper"8 "github.com/spf13/viper"
7 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
8 )11 )
9 12
10 func TestSetEngineDefaults(t *testing.T) {13 func TestSetEngineDefaults(t *testing.T) {
@@ -24,6 +27,12 @@ func TestSetEngineDefaults(t *testing.T) {
24 assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))27 assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))
25 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))28 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
26 29
30+ // Transcoding defaults
31+ assert.False(t, mgr.viper.GetBool("transcoding.enabled"))
32+ assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel"))
33+ assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent"))
34+ assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality"))
35+
27 // Old sections should NOT be registered as defaults36 // Old sections should NOT be registered as defaults
28 assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))37 assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))
29 assert.Empty(t, mgr.viper.GetString("rendering.mode"))38 assert.Empty(t, mgr.viper.GetString("rendering.mode"))
@@ -39,3 +48,34 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
39 48
40 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)49 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
41 }50 }
51+
52+func TestLoad_AppliesTranscodingDefaultsWhenSectionMissing(t *testing.T) {
53+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
54+ t.Setenv("XDG_DATA_HOME", t.TempDir())
55+ t.Setenv("XDG_STATE_HOME", t.TempDir())
56+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
57+
58+ configDir, err := GetConfigDir()
59+ require.NoError(t, err)
60+
61+ err = os.MkdirAll(configDir, dirPerm)
62+ require.NoError(t, err)
63+
64+ configPath := filepath.Join(configDir, "config.toml")
65+ err = os.WriteFile(configPath, []byte(`
66+[engine]
67+type = "cef"
68+`), filePerm)
69+ require.NoError(t, err)
70+
71+ mgr, err := NewManager()
72+ require.NoError(t, err)
73+ require.NoError(t, mgr.Load())
74+
75+ cfg := mgr.Get()
76+ require.NotNil(t, cfg)
77+ assert.False(t, cfg.Transcoding.Enabled)
78+ assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
79+ assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
80+ assert.Equal(t, "medium", cfg.Transcoding.Quality)
81+}
M internal/infrastructure/config/schema.go +19 -0
@@ -98,6 +98,8 @@ type Config struct {
98 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`98 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
99 // Engine holds engine selection and unified engine options.99 // Engine holds engine selection and unified engine options.
100 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`100 Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`
101+ // Transcoding controls GPU-accelerated media transcoding for proprietary codecs.
102+ Transcoding TranscodingConfig `mapstructure:"transcoding" yaml:"transcoding" toml:"transcoding"`
101 }103 }
102 104
103 // CookiePolicy controls cookie acceptance behavior.105 // CookiePolicy controls cookie acceptance behavior.
@@ -362,3 +364,20 @@ type DownloadsConfig struct {
362 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.364 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
363 Path string `mapstructure:"path" yaml:"path" toml:"path"`365 Path string `mapstructure:"path" yaml:"path" toml:"path"`
364 }366 }
367+
368+// TranscodingConfig controls GPU-accelerated media transcoding.
369+// When enabled, proprietary video formats (H.264, HEVC) are transcoded
370+// to open codecs (VP9, AV1) that CEF can decode natively.
371+type TranscodingConfig struct {
372+ // Enabled controls whether transcoding is active (default: false).
373+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
374+ // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
375+ // Default: "auto" (tries VAAPI first, then CUDA).
376+ HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"`
377+ // MaxConcurrent is the maximum number of simultaneous transcode sessions.
378+ // Default: 3.
379+ MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"`
380+ // Quality controls the encode quality preset: "low", "medium", or "high".
381+ // Default: "medium".
382+ Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"`
383+}
M internal/infrastructure/config/schema_provider.go +50 -0
@@ -847,6 +847,41 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
847 Description: "Enable browser developer tools (F12)",847 Description: "Enable browser developer tools (F12)",
848 Section: SectionDebug,848 Section: SectionDebug,
849 },849 },
850+ {
851+ Key: "engine.cef.log_file",
852+ Type: "string",
853+ Default: defaults.Engine.CEF.LogFile,
854+ Description: "Path to the CEF runtime log file written during startup and browser process execution",
855+ Section: SectionDebug,
856+ },
857+ {
858+ Key: "engine.cef.log_severity",
859+ Type: "int32",
860+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.LogSeverity),
861+ Description: "CEF internal log verbosity (0=default, 1=verbose, 2=info, 3=warning, 4=error, 99=disable)",
862+ Section: SectionDebug,
863+ },
864+ {
865+ Key: "engine.cef.trace_handlers",
866+ Type: "bool",
867+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.TraceHandlers),
868+ Description: "Trace purego-cef handler wrapping and refcount activity",
869+ Section: SectionDebug,
870+ },
871+ {
872+ Key: "engine.cef.enable_audio_handler",
873+ Type: "bool",
874+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableAudioHandler),
875+ Description: "Enable the experimental CEF audio handler bridge",
876+ Section: SectionDebug,
877+ },
878+ {
879+ Key: "engine.cef.enable_context_menu_handler",
880+ Type: "bool",
881+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler),
882+ Description: "Enable the experimental CEF context menu handler bridge",
883+ Section: SectionDebug,
884+ },
850 }885 }
851 }886 }
852 887
@@ -876,6 +911,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
876 Range: ">=0",911 Range: ">=0",
877 Section: SectionPerformance,912 Section: SectionPerformance,
878 },913 },
914+ {
915+ Key: "engine.cef.windowless_frame_rate",
916+ Type: "int32",
917+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
918+ Description: "Maximum off-screen frame rate for the CEF engine",
919+ Range: ">=0",
920+ Section: SectionPerformance,
921+ },
879 {922 {
880 Key: "engine.webkit.skia_cpu_painting_threads",923 Key: "engine.webkit.skia_cpu_painting_threads",
881 Type: "int",924 Type: "int",
@@ -968,6 +1011,13 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
968 1011
969 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {1012 func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {
970 return []entity.ConfigKeyInfo{1013 return []entity.ConfigKeyInfo{
1014+ {
1015+ Key: "engine.cef.cef_dir",
1016+ Type: "string",
1017+ Default: defaults.Engine.CEF.CEFDir,
1018+ Description: "Custom CEF runtime directory containing libcef and Resources",
1019+ Section: SectionRuntime,
1020+ },
971 {1021 {
972 Key: "engine.webkit.prefix",1022 Key: "engine.webkit.prefix",
973 Type: "string",1023 Type: "string",
M internal/infrastructure/config/validation.go +29 -0
@@ -10,6 +10,8 @@ import (
10 domainvalidation "github.com/bnema/dumber/internal/domain/validation"10 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
11 )11 )
12 12
13+const cefLogSeverityDisabled = 99
14+
13 // validateConfig performs comprehensive validation of configuration values15 // validateConfig performs comprehensive validation of configuration values
14 func validateConfig(config *Config) error {16 func validateConfig(config *Config) error {
15 var validationErrors []string17 var validationErrors []string
@@ -32,6 +34,7 @@ func validateConfig(config *Config) error {
32 validationErrors = append(validationErrors, validateColorScheme(config)...)34 validationErrors = append(validationErrors, validateColorScheme(config)...)
33 validationErrors = append(validationErrors, validateSession(config)...)35 validationErrors = append(validationErrors, validateSession(config)...)
34 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)36 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
37+ validationErrors = append(validationErrors, validateCEF(config)...)
35 38
36 // If there are validation errors, return them39 // If there are validation errors, return them
37 if len(validationErrors) > 0 {40 if len(validationErrors) > 0 {
@@ -324,6 +327,26 @@ func validateLogging(config *Config) []string {
324 return validationErrors327 return validationErrors
325 }328 }
326 329
330+func validateCEF(config *Config) []string {
331+ var validationErrors []string
332+
333+ switch config.Engine.CEF.LogSeverity {
334+ case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
335+ default:
336+ validationErrors = append(validationErrors, fmt.Sprintf(
337+ "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)",
338+ cefLogSeverityDisabled,
339+ config.Engine.CEF.LogSeverity,
340+ ))
341+ }
342+
343+ if config.Engine.CEF.WindowlessFrameRate < 0 {
344+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
345+ }
346+
347+ return validationErrors
348+}
349+
327 func validateWorkspaceNewPaneURL(config *Config) []string {350 func validateWorkspaceNewPaneURL(config *Config) []string {
328 var validationErrors []string351 var validationErrors []string
329 352
@@ -441,6 +464,12 @@ func validateSession(config *Config) []string {
441 func validatePerformanceProfile(config *Config) []string {464 func validatePerformanceProfile(config *Config) []string {
442 var validationErrors []string465 var validationErrors []string
443 466
467+ // Performance profiles and webkit-specific tuning only apply to the webkit engine.
468+ engineType := config.Engine.ResolveEngineType()
469+ if engineType != "webkit" {
470+ return nil
471+ }
472+
444 // Validate profile name473 // Validate profile name
445 if !IsValidPerformanceProfile(config.Engine.Profile) {474 if !IsValidPerformanceProfile(config.Engine.Profile) {
446 validationErrors = append(validationErrors, fmt.Sprintf(475 validationErrors = append(validationErrors, fmt.Sprintf(
M internal/infrastructure/config/validation_test.go +54 -0
@@ -34,3 +34,57 @@ func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
34 })34 })
35 }35 }
36 }36 }
37+
38+func TestValidateConfig_CEFConfig(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ mutate func(*Config)
42+ wantErr bool
43+ wantText string
44+ }{
45+ {
46+ name: "valid defaults",
47+ mutate: func(_ *Config) {
48+ },
49+ wantErr: false,
50+ },
51+ {
52+ name: "invalid log severity",
53+ mutate: func(cfg *Config) {
54+ cfg.Engine.CEF.LogSeverity = 7
55+ },
56+ wantErr: true,
57+ wantText: "engine.cef.log_severity",
58+ },
59+ {
60+ name: "zero frame rate uses default",
61+ mutate: func(cfg *Config) {
62+ cfg.Engine.CEF.WindowlessFrameRate = 0
63+ },
64+ wantErr: false,
65+ },
66+ {
67+ name: "negative frame rate",
68+ mutate: func(cfg *Config) {
69+ cfg.Engine.CEF.WindowlessFrameRate = -1
70+ },
71+ wantErr: true,
72+ wantText: "engine.cef.windowless_frame_rate",
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ cfg := DefaultConfig()
79+ tt.mutate(cfg)
80+
81+ err := validateConfig(cfg)
82+ if tt.wantErr {
83+ require.Error(t, err)
84+ assert.Contains(t, err.Error(), tt.wantText)
85+ return
86+ }
87+ require.NoError(t, err)
88+ })
89+ }
90+}
A internal/infrastructure/config/webui_payload.go +123 -0
@@ -0,0 +1,123 @@
1+package config
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+// WebUIConfigAppearancePayload is the JSON shape expected by dumb://config.
6+type WebUIConfigAppearancePayload struct {
7+ SansFont string `json:"sans_font"`
8+ SerifFont string `json:"serif_font"`
9+ MonospaceFont string `json:"monospace_font"`
10+ DefaultFontSize int `json:"default_font_size"`
11+ ColorScheme string `json:"color_scheme"`
12+ LightPalette ColorPalette `json:"light_palette"`
13+ DarkPalette ColorPalette `json:"dark_palette"`
14+}
15+
16+// WebUIConfigHardwarePayload exposes detected hardware to the settings UI.
17+type WebUIConfigHardwarePayload struct {
18+ CPUCores int `json:"cpu_cores"`
19+ CPUThreads int `json:"cpu_threads"`
20+ TotalRAMMB int `json:"total_ram_mb"`
21+ GPUVendor string `json:"gpu_vendor"`
22+ GPUName string `json:"gpu_name"`
23+ VRAMMB int `json:"vram_mb"`
24+}
25+
26+// WebUIConfigCustomPerformancePayload holds user-editable custom profile values.
27+type WebUIConfigCustomPerformancePayload struct {
28+ SkiaCPUThreads int `json:"skia_cpu_threads"`
29+ SkiaGPUThreads int `json:"skia_gpu_threads"`
30+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
31+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
32+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
33+}
34+
35+// WebUIConfigResolvedPerformancePayload holds the resolved effective values.
36+type WebUIConfigResolvedPerformancePayload struct {
37+ SkiaCPUThreads int `json:"skia_cpu_threads"`
38+ SkiaGPUThreads int `json:"skia_gpu_threads"`
39+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
40+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
41+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
42+ ConservativeThreshold float64 `json:"conservative_threshold"`
43+ StrictThreshold float64 `json:"strict_threshold"`
44+}
45+
46+// WebUIConfigPerformancePayload is the performance tab payload expected by the UI.
47+type WebUIConfigPerformancePayload struct {
48+ Profile string `json:"profile"`
49+ Custom WebUIConfigCustomPerformancePayload `json:"custom"`
50+ Resolved WebUIConfigResolvedPerformancePayload `json:"resolved"`
51+ Hardware WebUIConfigHardwarePayload `json:"hardware"`
52+}
53+
54+// WebUIConfigPayload is the full JSON shape expected by dumb://config.
55+type WebUIConfigPayload struct {
56+ Appearance WebUIConfigAppearancePayload `json:"appearance"`
57+ Performance WebUIConfigPerformancePayload `json:"performance"`
58+ DefaultUIScale float64 `json:"default_ui_scale"`
59+ DefaultSearchEngine string `json:"default_search_engine"`
60+ SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
61+ EngineType string `json:"engine_type"`
62+}
63+
64+// BuildWebUIConfigPayload projects the full config into the stable DTO used by
65+// the settings page across engines.
66+func BuildWebUIConfigPayload(cfg *Config, hw *port.HardwareInfo) WebUIConfigPayload {
67+ if cfg == nil {
68+ return WebUIConfigPayload{}
69+ }
70+
71+ perfCfg := PerformanceConfigFromEngine(&cfg.Engine)
72+ resolved := ResolvePerformanceProfile(&perfCfg, hw)
73+
74+ return WebUIConfigPayload{
75+ DefaultUIScale: cfg.DefaultUIScale,
76+ DefaultSearchEngine: cfg.DefaultSearchEngine,
77+ SearchShortcuts: cfg.SearchShortcuts,
78+ EngineType: cfg.Engine.ResolveEngineType(),
79+ Appearance: WebUIConfigAppearancePayload{
80+ SansFont: cfg.Appearance.SansFont,
81+ SerifFont: cfg.Appearance.SerifFont,
82+ MonospaceFont: cfg.Appearance.MonospaceFont,
83+ DefaultFontSize: cfg.Appearance.DefaultFontSize,
84+ ColorScheme: cfg.Appearance.ColorScheme,
85+ LightPalette: cfg.Appearance.LightPalette,
86+ DarkPalette: cfg.Appearance.DarkPalette,
87+ },
88+ Performance: WebUIConfigPerformancePayload{
89+ Profile: string(cfg.Engine.Profile),
90+ Custom: WebUIConfigCustomPerformancePayload{
91+ SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
92+ SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
93+ WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
94+ NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
95+ WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
96+ },
97+ Resolved: WebUIConfigResolvedPerformancePayload{
98+ SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
99+ SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
100+ WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
101+ NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
102+ WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
103+ ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
104+ StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
105+ },
106+ Hardware: buildWebUIHardwarePayload(hw),
107+ },
108+ }
109+}
110+
111+func buildWebUIHardwarePayload(hw *port.HardwareInfo) WebUIConfigHardwarePayload {
112+ if hw == nil {
113+ return WebUIConfigHardwarePayload{}
114+ }
115+ return WebUIConfigHardwarePayload{
116+ CPUCores: hw.CPUCores,
117+ CPUThreads: hw.CPUThreads,
118+ TotalRAMMB: hw.TotalRAMMB(),
119+ GPUVendor: string(hw.GPUVendor),
120+ GPUName: hw.GPUName,
121+ VRAMMB: hw.VRAMMB(),
122+ }
123+}
M internal/infrastructure/config/writer_test.go +22 -0
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "bytes"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -43,6 +44,27 @@ func TestWriteConfigOrdered(t *testing.T) {
43 }44 }
44 }45 }
45 46
47+ if !strings.Contains(string(content), "[transcoding]") {
48+ t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
49+ }
50+ // Match "enabled = false" specifically within the [transcoding] section to
51+ // avoid false positives from other sections that may also have an enabled key.
52+ transcodingIdx := bytes.Index(content, []byte("[transcoding]"))
53+ if transcodingIdx < 0 {
54+ t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
55+ }
56+ afterTranscoding := content[transcodingIdx:]
57+ nextSection := bytes.Index(afterTranscoding[1:], []byte("\n["))
58+ var transcodingBlock []byte
59+ if nextSection >= 0 {
60+ transcodingBlock = afterTranscoding[:nextSection+1]
61+ } else {
62+ transcodingBlock = afterTranscoding
63+ }
64+ if !bytes.Contains(transcodingBlock, []byte("enabled = false")) {
65+ t.Fatalf("expected enabled = false in [transcoding] section, got:\n%s", string(transcodingBlock))
66+ }
67+
46 t.Logf("Found %d sections, all sorted", len(sections))68 t.Logf("Found %d sections, all sorted", len(sections))
47 69
48 // Print first 50 lines for debugging70 // Print first 50 lines for debugging
M internal/infrastructure/favicon/cache.go +18 -2
@@ -1,11 +1,13 @@
1 package favicon1 package favicon
2 2
3 import (3 import (
4+ "context"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "sync"7 "sync"
7 8
8 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/logging"
9 )11 )
10 12
11 const (13 const (
@@ -51,16 +53,21 @@ func NewCache(diskDir string) *Cache {
51 // Get retrieves favicon bytes for a domain.53 // Get retrieves favicon bytes for a domain.
52 // Checks memory cache first, then disk cache.54 // Checks memory cache first, then disk cache.
53 // Returns the bytes and true if found, nil and false otherwise.55 // Returns the bytes and true if found, nil and false otherwise.
54-func (c *Cache) Get(domain string) ([]byte, bool) {56+func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) {
55 if domain == "" {57 if domain == "" {
56 return nil, false58 return nil, false
57 }59 }
60+ log := logging.FromContext(ctx)
58 61
59 // Check memory cache first62 // Check memory cache first
60 c.mu.RLock()63 c.mu.RLock()
61 data, ok := c.memCache[domain]64 data, ok := c.memCache[domain]
62 c.mu.RUnlock()65 c.mu.RUnlock()
63 if ok {66 if ok {
67+ log.Debug().
68+ Str("domain", domain).
69+ Int("bytes", len(data)).
70+ Msg("favicon: Cache.Get memory hit")
64 return data, true71 return data, true
65 }72 }
66 73
@@ -71,18 +78,27 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
71 c.mu.Lock()78 c.mu.Lock()
72 c.memCache[domain] = data79 c.memCache[domain] = data
73 c.mu.Unlock()80 c.mu.Unlock()
81+ log.Debug().
82+ Str("domain", domain).
83+ Int("bytes", len(data)).
84+ Msg("favicon: Cache.Get disk hit")
74 return data, true85 return data, true
75 }86 }
76 87
88+ log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss")
77 return nil, false89 return nil, false
78 }90 }
79 91
80 // Set stores favicon bytes for a domain.92 // Set stores favicon bytes for a domain.
81 // Writes to memory cache immediately and queues async disk write.93 // Writes to memory cache immediately and queues async disk write.
82-func (c *Cache) Set(domain string, data []byte) {94+func (c *Cache) Set(ctx context.Context, domain string, data []byte) {
83 if domain == "" || len(data) == 0 {95 if domain == "" || len(data) == 0 {
84 return96 return
85 }97 }
98+ logging.FromContext(ctx).Debug().
99+ Str("domain", domain).
100+ Int("bytes", len(data)).
101+ Msg("favicon: Cache.Set")
86 102
87 // Write to memory cache103 // Write to memory cache
88 c.mu.Lock()104 c.mu.Lock()
M internal/infrastructure/favicon/service.go +48 -11
@@ -37,27 +37,39 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
37 if domain == "" {37 if domain == "" {
38 return nil, nil38 return nil, nil
39 }39 }
40+ log := logging.FromContext(ctx)
41+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get begin")
40 42
41 // Internal domain returns the app logo43 // Internal domain returns the app logo
42 if domain == InternalDomain {44 if domain == InternalDomain {
45+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain")
43 return assets.LogoSVG, nil46 return assets.LogoSVG, nil
44 }47 }
45 48
46 // Check cache first (memory + disk)49 // Check cache first (memory + disk)
47- if data, ok := s.cache.Get(domain); ok {50+ if data, ok := s.cache.Get(ctx, domain); ok {
51+ log.Debug().
52+ Str("domain", domain).
53+ Int("bytes", len(data)).
54+ Msg("favicon: Service.Get cache hit")
48 return data, nil55 return data, nil
49 }56 }
50 57
51 // Fetch from external API58 // Fetch from external API
52 data, err := s.fetcher.Fetch(ctx, domain)59 data, err := s.fetcher.Fetch(ctx, domain)
53 if err != nil {60 if err != nil {
61+ log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error")
54 return nil, err62 return nil, err
55 }63 }
56 64
57 // Store in cache if we got data65 // Store in cache if we got data
58 if len(data) > 0 {66 if len(data) > 0 {
59- s.cache.Set(domain, data)67+ s.cache.Set(ctx, domain, data)
60 }68 }
69+ log.Debug().
70+ Str("domain", domain).
71+ Int("bytes", len(data)).
72+ Msg("favicon: Service.Get end")
61 73
62 return data, nil74 return data, nil
63 }75 }
@@ -74,13 +86,19 @@ func GetLogoBytes() []byte {
74 }86 }
75 87
76 // GetCached returns favicon bytes only if already cached (no external fetch).88 // GetCached returns favicon bytes only if already cached (no external fetch).
77-func (s *Service) GetCached(domain string) ([]byte, bool) {89+func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) {
78- return s.cache.Get(domain)90+ data, ok := s.cache.Get(ctx, domain)
91+ logging.FromContext(ctx).Debug().
92+ Str("domain", domain).
93+ Bool("hit", ok).
94+ Int("bytes", len(data)).
95+ Msg("favicon: Service.GetCached")
96+ return data, ok
79 }97 }
80 98
81 // Store saves favicon bytes for a domain to cache.99 // Store saves favicon bytes for a domain to cache.
82-func (s *Service) Store(domain string, data []byte) error {100+func (s *Service) Store(ctx context.Context, domain string, data []byte) error {
83- s.cache.Set(domain, data)101+ s.cache.Set(ctx, domain, data)
84 return nil102 return nil
85 }103 }
86 104
@@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
115 if domain == "" {133 if domain == "" {
116 return134 return
117 }135 }
136+ log := logging.FromContext(ctx)
137+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin")
118 138
119 // Already on disk?139 // Already on disk?
120 if s.cache.HasOnDisk(domain) {140 if s.cache.HasOnDisk(domain) {
141+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk")
121 return142 return
122 }143 }
123 144
124- log := logging.FromContext(ctx)
125-
126 // Fetch and store145 // Fetch and store
127 data, err := s.fetcher.Fetch(ctx, domain)146 data, err := s.fetcher.Fetch(ctx, domain)
128 if err != nil {147 if err != nil {
@@ -131,8 +150,12 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
131 }150 }
132 151
133 if len(data) > 0 {152 if len(data) > 0 {
134- s.cache.Set(domain, data)153+ s.cache.Set(ctx, domain, data)
135 }154 }
155+ log.Debug().
156+ Str("domain", domain).
157+ Int("bytes", len(data)).
158+ Msg("favicon: EnsureDiskCache end")
136 }159 }
137 160
138 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.161 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -151,9 +174,18 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
151 if domain == "" {174 if domain == "" {
152 return nil175 return nil
153 }176 }
177+ log := logging.FromContext(ctx)
178+ log.Debug().
179+ Str("domain", domain).
180+ Int("size", size).
181+ Msg("favicon: EnsureSizedPNG begin")
154 182
155 // Already exists?183 // Already exists?
156 if s.HasPNGSizedOnDisk(domain, size) {184 if s.HasPNGSizedOnDisk(domain, size) {
185+ log.Debug().
186+ Str("domain", domain).
187+ Int("size", size).
188+ Msg("favicon: EnsureSizedPNG already exists")
157 return nil189 return nil
158 }190 }
159 191
@@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
168 return fmt.Errorf("cannot determine destination path for domain %s", domain)200 return fmt.Errorf("cannot determine destination path for domain %s", domain)
169 }201 }
170 202
171- log := logging.FromContext(ctx)
172 log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")203 log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")
173 204
174- return ResizePNG(srcPath, dstPath, size)205+ err := ResizePNG(srcPath, dstPath, size)
206+ log.Debug().
207+ Err(err).
208+ Str("domain", domain).
209+ Int("size", size).
210+ Msg("favicon: EnsureSizedPNG end")
211+ return err
175 }212 }
176 213
177 // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.214 // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
M internal/infrastructure/filtering/manager.go +14 -0
@@ -169,6 +169,20 @@ func (m *Manager) loadAsyncWorker(ctx context.Context) {
169 return169 return
170 }170 }
171 171
172+ // If local JSON files exist on disk, compile from them directly instead
173+ // of downloading. This respects auto_update=false on cold start with an
174+ // empty compiled store and allows testing with patched local JSON.
175+ if paths := m.downloader.GetCachedFilterPaths(); len(paths) > 0 {
176+ log.Info().Int("files", len(paths)).Msg("compiling filters from local JSON (skipping download)")
177+ filters, err := m.compileFilterParts(ctx, paths)
178+ if err == nil {
179+ version := m.setActiveFilters(filters, "Filters active")
180+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled from local JSON")
181+ return
182+ }
183+ log.Warn().Err(err).Msg("failed to compile local JSON, falling back to download")
184+ }
185+
172 m.downloadCompileAndActivate(ctx)186 m.downloadCompileAndActivate(ctx)
173 }187 }
174 188
M internal/infrastructure/filtering/manager_test.go +14 -0
@@ -51,6 +51,10 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
51 Return(nil, nil) // nil filter triggers fallback to download51 Return(nil, nil) // nil filter triggers fallback to download
52 52
53 // Fallback: download will be triggered since Load returned nil filter53 // Fallback: download will be triggered since Load returned nil filter
54+ mockDownloader.EXPECT().
55+ GetCachedFilterPaths().
56+ Return(nil)
57+
54 mockDownloader.EXPECT().58 mockDownloader.EXPECT().
55 DownloadFilters(mock.Anything, mock.Anything).59 DownloadFilters(mock.Anything, mock.Anything).
56 Return([]string{jsonFile}, nil)60 Return([]string{jsonFile}, nil)
@@ -140,6 +144,10 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
140 Return([]string{}, nil)144 Return([]string{}, nil)
141 145
142 // Download should be triggered146 // Download should be triggered
147+ mockDownloader.EXPECT().
148+ GetCachedFilterPaths().
149+ Return(nil)
150+
143 mockDownloader.EXPECT().151 mockDownloader.EXPECT().
144 DownloadFilters(mock.Anything, mock.Anything).152 DownloadFilters(mock.Anything, mock.Anything).
145 Return([]string{jsonFile}, nil)153 Return([]string{jsonFile}, nil)
@@ -455,6 +463,9 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
455 mockStore.EXPECT().463 mockStore.EXPECT().
456 Load(mock.Anything, "ublock-combined-0").464 Load(mock.Anything, "ublock-combined-0").
457 Return(nil, nil) // nil triggers download fallback465 Return(nil, nil) // nil triggers download fallback
466+ mockDownloader.EXPECT().
467+ GetCachedFilterPaths().
468+ Return(nil)
458 mockDownloader.EXPECT().469 mockDownloader.EXPECT().
459 DownloadFilters(mock.Anything, mock.Anything).470 DownloadFilters(mock.Anything, mock.Anything).
460 Return([]string{jsonFile}, nil)471 Return([]string{jsonFile}, nil)
@@ -519,6 +530,9 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
519 mockStore.EXPECT().530 mockStore.EXPECT().
520 FetchIdentifiers(mock.Anything).531 FetchIdentifiers(mock.Anything).
521 Return([]string{}, nil)532 Return([]string{}, nil)
533+ mockDownloader.EXPECT().
534+ GetCachedFilterPaths().
535+ Return(nil)
522 536
523 mockDownloader.EXPECT().537 mockDownloader.EXPECT().
524 DownloadFilters(mock.Anything, mock.Anything).538 DownloadFilters(mock.Anything, mock.Anything).
R internal/infrastructure/webkit/handlers/clipboard.go → internal/infrastructure/handlers/clipboard.go +3 -4 renamed, 89% similar
@@ -5,7 +5,6 @@ import (
5 "encoding/json"5 "encoding/json"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/webkit"
9 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
10 )9 )
11 10
@@ -32,8 +31,8 @@ type autoCopyRequest struct {
32 31
33 // HandleAutoCopySelection handles the auto_copy_selection message from JS.32 // HandleAutoCopySelection handles the auto_copy_selection message from JS.
34 // It copies the selected text to the clipboard if the feature is enabled.33 // It copies the selected text to the clipboard if the feature is enabled.
35-func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {34+func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
36- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {35+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
37 log := logging.FromContext(ctx)36 log := logging.FromContext(ctx)
38 37
39 // Check if feature is enabled38 // Check if feature is enabled
@@ -71,7 +70,7 @@ func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
71 // RegisterClipboardHandlers registers clipboard handlers with the router.70 // RegisterClipboardHandlers registers clipboard handlers with the router.
72 func RegisterClipboardHandlers(71 func RegisterClipboardHandlers(
73 ctx context.Context,72 ctx context.Context,
74- router *webkit.MessageRouter,73+ router port.WebUIHandlerRouter,
75 clipboard port.Clipboard,74 clipboard port.Clipboard,
76 autoCopyConfig port.AutoCopyConfig,75 autoCopyConfig port.AutoCopyConfig,
77 onCopied func(textLen int),76 onCopied func(textLen int),
R internal/infrastructure/webkit/handlers/config.go → internal/infrastructure/handlers/config.go +2 -3 renamed, 90% similar
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
@@ -25,7 +24,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error)
25 }24 }
26 25
27 // Handle processes the save_config message.26 // Handle processes the save_config message.
28-func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {27+func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
29 if h == nil {28 if h == nil {
30 return nil, fmt.Errorf("config handler is nil")29 return nil, fmt.Errorf("config handler is nil")
31 }30 }
@@ -52,7 +51,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload
52 51
53 // RegisterConfigHandlers registers configuration handlers with the router.52 // RegisterConfigHandlers registers configuration handlers with the router.
54 func RegisterConfigHandlers(53 func RegisterConfigHandlers(
55- ctx context.Context, router *webkit.MessageRouter,54+ ctx context.Context, router port.WebUIHandlerRouter,
56 saveConfig func(context.Context, port.WebUIConfig) error,55 saveConfig func(context.Context, port.WebUIConfig) error,
57 ) error {56 ) error {
58 handler := NewConfigHandler(saveConfig)57 handler := NewConfigHandler(saveConfig)
R internal/infrastructure/webkit/handlers/homepage/favorites.go → internal/infrastructure/handlers/homepage/favorites.go +8 -9 renamed, 80% similar
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
@@ -21,8 +20,8 @@ func NewFavoritesHandlers(favoritesUC port.HomepageFavorites) *FavoritesHandlers
21 }20 }
22 21
23 // HandleList handles favorite_list messages.22 // HandleList handles favorite_list messages.
24-func (h *FavoritesHandlers) HandleList() webkit.MessageHandler {23+func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler {
25- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {24+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
26 log := logging.FromContext(ctx)25 log := logging.FromContext(ctx)
27 26
28 requestID := ParseRequestID(payload)27 requestID := ParseRequestID(payload)
@@ -48,8 +47,8 @@ type setShortcutRequest struct {
48 }47 }
49 48
50 // HandleSetShortcut handles favorite_set_shortcut messages.49 // HandleSetShortcut handles favorite_set_shortcut messages.
51-func (h *FavoritesHandlers) HandleSetShortcut() webkit.MessageHandler {50+func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler {
52- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {51+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
53 log := logging.FromContext(ctx)52 log := logging.FromContext(ctx)
54 53
55 var req setShortcutRequest54 var req setShortcutRequest
@@ -77,8 +76,8 @@ type getByShortcutRequest struct {
77 }76 }
78 77
79 // HandleGetByShortcut handles favorite_get_by_shortcut messages.78 // HandleGetByShortcut handles favorite_get_by_shortcut messages.
80-func (h *FavoritesHandlers) HandleGetByShortcut() webkit.MessageHandler {79+func (h *FavoritesHandlers) HandleGetByShortcut() port.WebUIMessageHandler {
81- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {80+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
82 log := logging.FromContext(ctx)81 log := logging.FromContext(ctx)
83 82
84 var req getByShortcutRequest83 var req getByShortcutRequest
@@ -108,8 +107,8 @@ type setFolderRequest struct {
108 }107 }
109 108
110 // HandleSetFolder handles favorite_set_folder messages.109 // HandleSetFolder handles favorite_set_folder messages.
111-func (h *FavoritesHandlers) HandleSetFolder() webkit.MessageHandler {110+func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
112- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {111+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
113 log := logging.FromContext(ctx)112 log := logging.FromContext(ctx)
114 113
115 var req setFolderRequest114 var req setFolderRequest
R internal/infrastructure/webkit/handlers/homepage/folders.go → internal/infrastructure/handlers/homepage/folders.go +8 -9 renamed, 81% similar
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
@@ -21,8 +20,8 @@ func NewFolderHandlers(favoritesUC port.HomepageFavorites) *FolderHandlers {
21 }20 }
22 21
23 // HandleList handles folder_list messages.22 // HandleList handles folder_list messages.
24-func (h *FolderHandlers) HandleList() webkit.MessageHandler {23+func (h *FolderHandlers) HandleList() port.WebUIMessageHandler {
25- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {24+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
26 log := logging.FromContext(ctx)25 log := logging.FromContext(ctx)
27 26
28 requestID := ParseRequestID(payload)27 requestID := ParseRequestID(payload)
@@ -48,8 +47,8 @@ type createFolderRequest struct {
48 }47 }
49 48
50 // HandleCreate handles folder_create messages.49 // HandleCreate handles folder_create messages.
51-func (h *FolderHandlers) HandleCreate() webkit.MessageHandler {50+func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
52- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {51+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
53 log := logging.FromContext(ctx)52 log := logging.FromContext(ctx)
54 53
55 var req createFolderRequest54 var req createFolderRequest
@@ -86,8 +85,8 @@ type deleteFolderRequest struct {
86 }85 }
87 86
88 // HandleDelete handles folder_delete messages.87 // HandleDelete handles folder_delete messages.
89-func (h *FolderHandlers) HandleDelete() webkit.MessageHandler {88+func (h *FolderHandlers) HandleDelete() port.WebUIMessageHandler {
90- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {89+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
91 log := logging.FromContext(ctx)90 log := logging.FromContext(ctx)
92 91
93 var req deleteFolderRequest92 var req deleteFolderRequest
@@ -118,8 +117,8 @@ type updateFolderRequest struct {
118 117
119 // HandleUpdate handles folder_update messages.118 // HandleUpdate handles folder_update messages.
120 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.119 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.
121-func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler {120+func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
122- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {121+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
123 log := logging.FromContext(ctx)122 log := logging.FromContext(ctx)
124 123
125 var req updateFolderRequest124 var req updateFolderRequest
R internal/infrastructure/webkit/handlers/homepage/history.go → internal/infrastructure/handlers/homepage/history.go +16 -17 renamed, 81% similar
@@ -7,7 +7,6 @@ import (
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
11 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
12 )11 )
13 12
@@ -29,8 +28,8 @@ type timelineRequest struct {
29 }28 }
30 29
31 // HandleTimeline handles history_timeline messages.30 // HandleTimeline handles history_timeline messages.
32-func (h *HistoryHandlers) HandleTimeline() webkit.MessageHandler {31+func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
33- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {32+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
34 log := logging.FromContext(ctx)33 log := logging.FromContext(ctx)
35 34
36 var req timelineRequest35 var req timelineRequest
@@ -61,8 +60,8 @@ type searchRequest struct {
61 }60 }
62 61
63 // HandleSearchFTS handles history_search_fts messages.62 // HandleSearchFTS handles history_search_fts messages.
64-func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler {63+func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
65- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {64+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
66 log := logging.FromContext(ctx)65 log := logging.FromContext(ctx)
67 66
68 var req searchRequest67 var req searchRequest
@@ -100,8 +99,8 @@ type deleteEntryRequest struct {
100 }99 }
101 100
102 // HandleDeleteEntry handles history_delete_entry messages.101 // HandleDeleteEntry handles history_delete_entry messages.
103-func (h *HistoryHandlers) HandleDeleteEntry() webkit.MessageHandler {102+func (h *HistoryHandlers) HandleDeleteEntry() port.WebUIMessageHandler {
104- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {103+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
105 log := logging.FromContext(ctx)104 log := logging.FromContext(ctx)
106 105
107 var req deleteEntryRequest106 var req deleteEntryRequest
@@ -129,8 +128,8 @@ type deleteRangeRequest struct {
129 }128 }
130 129
131 // HandleDeleteRange handles history_delete_range messages.130 // HandleDeleteRange handles history_delete_range messages.
132-func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler {131+func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
133- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {132+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
134 log := logging.FromContext(ctx)133 log := logging.FromContext(ctx)
135 134
136 var req deleteRangeRequest135 var req deleteRangeRequest
@@ -160,8 +159,8 @@ func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler {
160 }159 }
161 160
162 // HandleClearAll handles history_clear_all messages.161 // HandleClearAll handles history_clear_all messages.
163-func (h *HistoryHandlers) HandleClearAll() webkit.MessageHandler {162+func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
164- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {163+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
165 log := logging.FromContext(ctx)164 log := logging.FromContext(ctx)
166 165
167 requestID := ParseRequestID(payload)166 requestID := ParseRequestID(payload)
@@ -199,8 +198,8 @@ func rangeToTime(r string) time.Time {
199 }198 }
200 199
201 // HandleAnalytics handles history_analytics messages.200 // HandleAnalytics handles history_analytics messages.
202-func (h *HistoryHandlers) HandleAnalytics() webkit.MessageHandler {201+func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {
203- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {202+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
204 log := logging.FromContext(ctx)203 log := logging.FromContext(ctx)
205 204
206 requestID := ParseRequestID(payload)205 requestID := ParseRequestID(payload)
@@ -225,8 +224,8 @@ type domainStatsRequest struct {
225 }224 }
226 225
227 // HandleDomainStats handles history_domain_stats messages.226 // HandleDomainStats handles history_domain_stats messages.
228-func (h *HistoryHandlers) HandleDomainStats() webkit.MessageHandler {227+func (h *HistoryHandlers) HandleDomainStats() port.WebUIMessageHandler {
229- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {228+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
230 log := logging.FromContext(ctx)229 log := logging.FromContext(ctx)
231 230
232 var req domainStatsRequest231 var req domainStatsRequest
@@ -255,8 +254,8 @@ type deleteDomainRequest struct {
255 }254 }
256 255
257 // HandleDeleteDomain handles history_delete_domain messages.256 // HandleDeleteDomain handles history_delete_domain messages.
258-func (h *HistoryHandlers) HandleDeleteDomain() webkit.MessageHandler {257+func (h *HistoryHandlers) HandleDeleteDomain() port.WebUIMessageHandler {
259- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {258+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
260 log := logging.FromContext(ctx)259 log := logging.FromContext(ctx)
261 260
262 var req deleteDomainRequest261 var req deleteDomainRequest
R internal/infrastructure/webkit/handlers/homepage/registry.go → internal/infrastructure/handlers/homepage/registry.go +2 -3 renamed, 93% similar
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/webkit"
9 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
10 )9 )
11 10
@@ -16,7 +15,7 @@ type Config struct {
16 }15 }
17 16
18 // RegisterHandlers registers all homepage message handlers with the router.17 // RegisterHandlers registers all homepage message handlers with the router.
19-func RegisterHandlers(ctx context.Context, router *webkit.MessageRouter, cfg Config) error {18+func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg Config) error {
20 log := logging.FromContext(ctx)19 log := logging.FromContext(ctx)
21 log.Debug().Msg("registering homepage message handlers")20 log.Debug().Msg("registering homepage message handlers")
22 21
@@ -25,7 +24,7 @@ func RegisterHandlers(ctx context.Context, router *webkit.MessageRouter, cfg Con
25 const errorCallback = "__dumber_error"24 const errorCallback = "__dumber_error"
26 const worldName = "" // main world25 const worldName = "" // main world
27 26
28- handlers := make(map[string]webkit.MessageHandler)27+ handlers := make(map[string]port.WebUIMessageHandler)
29 28
30 // History handlers29 // History handlers
31 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)30 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
R internal/infrastructure/webkit/handlers/homepage/response.go → internal/infrastructure/handlers/homepage/response.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/infrastructure/webkit/handlers/homepage/tags.go → internal/infrastructure/handlers/homepage/tags.go +12 -13 renamed, 79% similar
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
@@ -21,8 +20,8 @@ func NewTagHandlers(favoritesUC port.HomepageFavorites) *TagHandlers {
21 }20 }
22 21
23 // HandleList handles tag_list messages.22 // HandleList handles tag_list messages.
24-func (h *TagHandlers) HandleList() webkit.MessageHandler {23+func (h *TagHandlers) HandleList() port.WebUIMessageHandler {
25- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {24+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
26 log := logging.FromContext(ctx)25 log := logging.FromContext(ctx)
27 26
28 requestID := ParseRequestID(payload)27 requestID := ParseRequestID(payload)
@@ -48,8 +47,8 @@ type createTagRequest struct {
48 }47 }
49 48
50 // HandleCreate handles tag_create messages.49 // HandleCreate handles tag_create messages.
51-func (h *TagHandlers) HandleCreate() webkit.MessageHandler {50+func (h *TagHandlers) HandleCreate() port.WebUIMessageHandler {
52- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {51+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
53 log := logging.FromContext(ctx)52 log := logging.FromContext(ctx)
54 53
55 var req createTagRequest54 var req createTagRequest
@@ -83,8 +82,8 @@ type deleteTagRequest struct {
83 }82 }
84 83
85 // HandleDelete handles tag_delete messages.84 // HandleDelete handles tag_delete messages.
86-func (h *TagHandlers) HandleDelete() webkit.MessageHandler {85+func (h *TagHandlers) HandleDelete() port.WebUIMessageHandler {
87- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {86+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
88 log := logging.FromContext(ctx)87 log := logging.FromContext(ctx)
89 88
90 var req deleteTagRequest89 var req deleteTagRequest
@@ -115,8 +114,8 @@ type updateTagRequest struct {
115 114
116 // HandleUpdate handles tag_update messages.115 // HandleUpdate handles tag_update messages.
117 // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase.116 // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase.
118-func (h *TagHandlers) HandleUpdate() webkit.MessageHandler {117+func (h *TagHandlers) HandleUpdate() port.WebUIMessageHandler {
119- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {118+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
120 log := logging.FromContext(ctx)119 log := logging.FromContext(ctx)
121 120
122 var req updateTagRequest121 var req updateTagRequest
@@ -154,17 +153,17 @@ type tagAssignRequest struct {
154 }153 }
155 154
156 // HandleAssign handles tag_assign messages.155 // HandleAssign handles tag_assign messages.
157-func (h *TagHandlers) HandleAssign() webkit.MessageHandler {156+func (h *TagHandlers) HandleAssign() port.WebUIMessageHandler {
158 return handleTagAssign("tag_assign", h.favoritesUC.TagFavorite)157 return handleTagAssign("tag_assign", h.favoritesUC.TagFavorite)
159 }158 }
160 159
161 // HandleRemove handles tag_remove messages.160 // HandleRemove handles tag_remove messages.
162-func (h *TagHandlers) HandleRemove() webkit.MessageHandler {161+func (h *TagHandlers) HandleRemove() port.WebUIMessageHandler {
163 return handleTagAssign("tag_remove", h.favoritesUC.UntagFavorite)162 return handleTagAssign("tag_remove", h.favoritesUC.UntagFavorite)
164 }163 }
165 164
166-func handleTagAssign(action string, op func(context.Context, entity.FavoriteID, entity.TagID) error) webkit.MessageHandler {165+func handleTagAssign(action string, op func(context.Context, entity.FavoriteID, entity.TagID) error) port.WebUIMessageHandler {
167- return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {166+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
168 log := logging.FromContext(ctx)167 log := logging.FromContext(ctx)
169 168
170 var req tagAssignRequest169 var req tagAssignRequest
R internal/infrastructure/webkit/handlers/keybindings.go → internal/infrastructure/handlers/keybindings.go +9 -10 renamed, 84% similar
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
@@ -34,12 +33,12 @@ func NewKeybindingsHandler(
34 }33 }
35 34
36 // HandleGetKeybindings returns all keybindings grouped by mode.35 // HandleGetKeybindings returns all keybindings grouped by mode.
37-func (h *KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {36+func (h *KeybindingsHandler) HandleGetKeybindings(ctx context.Context, _ port.WebViewID, _ json.RawMessage) (any, error) {
38 return h.getUC.Execute(ctx)37 return h.getUC.Execute(ctx)
39 }38 }
40 39
41 // HandleSetKeybinding updates a single keybinding.40 // HandleSetKeybinding updates a single keybinding.
42-func (h *KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {41+func (h *KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
43 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()42 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
44 log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called")43 log.Debug().RawJSON("payload", payload).Msg("HandleSetKeybinding called")
45 44
@@ -61,7 +60,7 @@ func (h *KeybindingsHandler) HandleSetKeybinding(ctx context.Context, _ webkit.W
61 }60 }
62 61
63 // HandleResetKeybinding resets a keybinding to default.62 // HandleResetKeybinding resets a keybinding to default.
64-func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {63+func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
65 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()64 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
66 65
67 var req port.ResetKeybindingRequest66 var req port.ResetKeybindingRequest
@@ -79,7 +78,7 @@ func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit
79 }78 }
80 79
81 // HandleResetAllKeybindings resets all keybindings to defaults.80 // HandleResetAllKeybindings resets all keybindings to defaults.
82-func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {81+func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ port.WebViewID, _ json.RawMessage) (any, error) {
83 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()82 log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger()
84 log.Info().Msg("resetting all keybindings to defaults")83 log.Info().Msg("resetting all keybindings to defaults")
85 84
@@ -91,7 +90,7 @@ func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ we
91 }90 }
92 91
93 // RegisterKeybindingsHandlers registers keybindings handlers with the router.92 // RegisterKeybindingsHandlers registers keybindings handlers with the router.
94-func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRouter, handler *KeybindingsHandler) error {93+func RegisterKeybindingsHandlers(ctx context.Context, router port.WebUIHandlerRouter, handler *KeybindingsHandler) error {
95 log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()94 log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
96 95
97 // Get all keybindings96 // Get all keybindings
@@ -100,7 +99,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
100 "__dumber_keybindings_loaded",99 "__dumber_keybindings_loaded",
101 "__dumber_keybindings_error",100 "__dumber_keybindings_error",
102 "",101 "",
103- webkit.MessageHandlerFunc(handler.HandleGetKeybindings),102+ port.WebUIMessageHandlerFunc(handler.HandleGetKeybindings),
104 ); err != nil {103 ); err != nil {
105 return err104 return err
106 }105 }
@@ -111,7 +110,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
111 "__dumber_keybinding_set",110 "__dumber_keybinding_set",
112 "__dumber_keybinding_set_error",111 "__dumber_keybinding_set_error",
113 "",112 "",
114- webkit.MessageHandlerFunc(handler.HandleSetKeybinding),113+ port.WebUIMessageHandlerFunc(handler.HandleSetKeybinding),
115 ); err != nil {114 ); err != nil {
116 return err115 return err
117 }116 }
@@ -122,7 +121,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
122 "__dumber_keybinding_reset",121 "__dumber_keybinding_reset",
123 "__dumber_keybinding_reset_error",122 "__dumber_keybinding_reset_error",
124 "",123 "",
125- webkit.MessageHandlerFunc(handler.HandleResetKeybinding),124+ port.WebUIMessageHandlerFunc(handler.HandleResetKeybinding),
126 ); err != nil {125 ); err != nil {
127 return err126 return err
128 }127 }
@@ -133,7 +132,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout
133 "__dumber_keybindings_reset_all",132 "__dumber_keybindings_reset_all",
134 "__dumber_keybindings_reset_all_error",133 "__dumber_keybindings_reset_all_error",
135 "",134 "",
136- webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings),135+ port.WebUIMessageHandlerFunc(handler.HandleResetAllKeybindings),
137 ); err != nil {136 ); err != nil {
138 return err137 return err
139 }138 }
R internal/infrastructure/webkit/handlers/register.go → internal/infrastructure/handlers/register.go +29 -31 renamed, 50% similar
@@ -7,8 +7,8 @@ import (
7 "unicode/utf8"7 "unicode/utf8"
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/webkit"10+ "github.com/bnema/dumber/internal/infrastructure/handlers/homepage"
11- "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"11+ "github.com/bnema/dumber/internal/logging"
12 )12 )
13 13
14 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive14 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive
@@ -17,49 +17,43 @@ import (
17 // Deprecated: use port.AccentKeyHandler instead. This alias is kept for compatibility.17 // Deprecated: use port.AccentKeyHandler instead. This alias is kept for compatibility.
18 type AccentKeyHandler = port.AccentKeyHandler18 type AccentKeyHandler = port.AccentKeyHandler
19 19
20-// Config holds all dependencies for message handlers.
21-type Config struct {
22- HistoryUC port.HomepageHistory
23- FavoritesUC port.HomepageFavorites
24- Clipboard port.Clipboard
25- AutoCopyConfig port.AutoCopyConfig
26- SaveConfig func(context.Context, port.WebUIConfig) error // Pre-built by bootstrap (usecase.SaveWebUIConfigUseCase.Execute)
27- // KeybindingsHandler is required (not optional like other handlers) because the WebUI
28- // always needs keybinding read/write support regardless of configuration.
29- KeybindingsHandler *KeybindingsHandler // Pre-built by bootstrap
30- OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
31-}
32-
33 // RegisterAll registers all message handlers with the router.20 // RegisterAll registers all message handlers with the router.
34-func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error {21+// The router can be any engine's message router that implements port.WebUIHandlerRouter.
22+func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port.HandlerDependencies) error {
35 // Homepage handlers (history, favorites, folders, tags)23 // Homepage handlers (history, favorites, folders, tags)
36- if cfg.HistoryUC != nil && cfg.FavoritesUC != nil {24+ if deps.HistoryUC != nil && deps.FavoritesUC != nil {
37 if err := homepage.RegisterHandlers(ctx, router, homepage.Config{25 if err := homepage.RegisterHandlers(ctx, router, homepage.Config{
38- HistoryUC: cfg.HistoryUC,26+ HistoryUC: deps.HistoryUC,
39- FavoritesUC: cfg.FavoritesUC,27+ FavoritesUC: deps.FavoritesUC,
40 }); err != nil {28 }); err != nil {
41 return err29 return err
42 }30 }
43 }31 }
44 32
45 // Configuration handlers33 // Configuration handlers
46- if cfg.SaveConfig != nil {34+ if deps.SaveConfig != nil {
47- if err := RegisterConfigHandlers(ctx, router, cfg.SaveConfig); err != nil {35+ if err := RegisterConfigHandlers(ctx, router, deps.SaveConfig); err != nil {
48 return err36 return err
49 }37 }
50 }38 }
51 39
52 // Keybindings handlers (always available)40 // Keybindings handlers (always available)
53- if cfg.KeybindingsHandler == nil {41+ if deps.KeybindingsGetter == nil {
54- return fmt.Errorf("KeybindingsHandler is required")42+ return fmt.Errorf("KeybindingsGetter is required")
55 }43 }
56- if err := RegisterKeybindingsHandlers(ctx, router, cfg.KeybindingsHandler); err != nil {44+ kbHandler := NewKeybindingsHandler(
45+ deps.KeybindingsGetter,
46+ deps.KeybindingSetter,
47+ deps.KeybindingResetter,
48+ deps.AllKeybindingsResetter,
49+ )
50+ if err := RegisterKeybindingsHandlers(ctx, router, kbHandler); err != nil {
57 return err51 return err
58 }52 }
59 53
60 // Clipboard handlers (for auto-copy on selection feature)54 // Clipboard handlers (for auto-copy on selection feature)
61- if cfg.Clipboard != nil && cfg.AutoCopyConfig != nil {55+ if deps.Clipboard != nil && deps.AutoCopyConfig != nil {
62- if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.AutoCopyConfig, cfg.OnClipboardCopied); err != nil {56+ if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil {
63 return err57 return err
64 }58 }
65 }59 }
@@ -69,7 +63,7 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
69 63
70 // RegisterAccentHandlers registers accent key press/release handlers with the router.64 // RegisterAccentHandlers registers accent key press/release handlers with the router.
71 // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker).65 // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker).
72-func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error {66+func RegisterAccentHandlers(_ context.Context, router port.WebUIHandlerRouter, handler AccentKeyHandler) error {
73 if router == nil {67 if router == nil {
74 return fmt.Errorf("RegisterAccentHandlers: router must not be nil")68 return fmt.Errorf("RegisterAccentHandlers: router must not be nil")
75 }69 }
@@ -77,8 +71,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han
77 return fmt.Errorf("RegisterAccentHandlers: handler must not be nil")71 return fmt.Errorf("RegisterAccentHandlers: handler must not be nil")
78 }72 }
79 73
80- if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc(74+ if err := router.RegisterHandler("accent_key_press", port.WebUIMessageHandlerFunc(
81- func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {75+ func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
82 var p struct {76 var p struct {
83 Char string `json:"char"`77 Char string `json:"char"`
84 Shift bool `json:"shift"`78 Shift bool `json:"shift"`
@@ -88,6 +82,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han
88 }82 }
89 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 {
90 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")
91 }87 }
92 return nil, nil88 return nil, nil
93 },89 },
@@ -95,8 +91,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han
95 return err91 return err
96 }92 }
97 93
98- if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc(94+ if err := router.RegisterHandler("accent_key_release", port.WebUIMessageHandlerFunc(
99- func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {95+ func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
100 var p struct {96 var p struct {
101 Char string `json:"char"`97 Char string `json:"char"`
102 }98 }
@@ -105,6 +101,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han
105 }101 }
106 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 {
107 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")
108 }106 }
109 return nil, nil107 return nil, nil
110 },108 },
A internal/infrastructure/handlers/register_test.go +61 -0
@@ -0,0 +1,61 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRegisterAccentHandlers_NilRouter(t *testing.T) {
16+ handler := mocks.NewMockAccentKeyHandler(t)
17+ err := RegisterAccentHandlers(context.Background(), nil, handler)
18+ require.Error(t, err)
19+ assert.Contains(t, err.Error(), "router")
20+}
21+
22+func TestRegisterAccentHandlers_NilHandler(t *testing.T) {
23+ router := mocks.NewMockWebUIHandlerRouter(t)
24+ err := RegisterAccentHandlers(context.Background(), router, nil)
25+ require.Error(t, err)
26+ assert.Contains(t, err.Error(), "handler")
27+}
28+
29+func TestRegisterAccentHandlers_Success(t *testing.T) {
30+ router := mocks.NewMockWebUIHandlerRouter(t)
31+ handler := mocks.NewMockAccentKeyHandler(t)
32+
33+ router.EXPECT().RegisterHandler("accent_key_press", mock.AnythingOfType("port.WebUIMessageHandlerFunc")).Return(nil)
34+ router.EXPECT().RegisterHandler("accent_key_release", mock.AnythingOfType("port.WebUIMessageHandlerFunc")).Return(nil)
35+
36+ err := RegisterAccentHandlers(context.Background(), router, handler)
37+ require.NoError(t, err)
38+}
39+
40+func TestRegisterAccentHandlers_KeyPress(t *testing.T) {
41+ ctx := context.Background()
42+
43+ // Use a real router to capture the handler and invoke it.
44+ var captured port.WebUIMessageHandler
45+ router := mocks.NewMockWebUIHandlerRouter(t)
46+ router.EXPECT().RegisterHandler("accent_key_press", mock.AnythingOfType("port.WebUIMessageHandlerFunc")).
47+ Run(func(_ string, h port.WebUIMessageHandler) { captured = h }).
48+ Return(nil)
49+ router.EXPECT().RegisterHandler("accent_key_release", mock.AnythingOfType("port.WebUIMessageHandlerFunc")).Return(nil)
50+
51+ handler := mocks.NewMockAccentKeyHandler(t)
52+ handler.EXPECT().OnKeyPressed(ctx, 'e', true).Return(false)
53+
54+ err := RegisterAccentHandlers(ctx, router, handler)
55+ require.NoError(t, err)
56+ require.NotNil(t, captured)
57+
58+ payload := json.RawMessage(`{"char":"e","shift":true}`)
59+ _, err = captured.Handle(ctx, 0, payload)
60+ require.NoError(t, err)
61+}
A internal/infrastructure/transcoder/audio.go +415 -0
@@ -0,0 +1,415 @@
1+package transcoder
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "github.com/bnema/purego-ffmpeg/ffmpeg"
9+ "github.com/rs/zerolog"
10+)
11+
12+// opusSampleRate is the standard output sample rate for Opus encoding.
13+// Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate.
14+const opusSampleRate = 48000
15+
16+// opusBitrate is the default Opus encoding bitrate (128 kbps stereo).
17+const opusBitrate = 128000
18+
19+// opusFrameSize is the number of samples per Opus frame at 48 kHz.
20+// 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration.
21+const opusFrameSize = 960
22+
23+func freeCodecContext(ctx *unsafe.Pointer) {
24+ //nolint:gosec // FFmpeg expects AVCodecContext** for ownership release.
25+ ffmpeg.CodecFreeContext(unsafe.Pointer(ctx))
26+}
27+
28+func freeFrame(frame *unsafe.Pointer) {
29+ //nolint:gosec // FFmpeg expects AVFrame** for ownership release.
30+ ffmpeg.FrameFree(unsafe.Pointer(frame))
31+}
32+
33+func freeSwrContext(swr *unsafe.Pointer) {
34+ //nolint:gosec // FFmpeg expects SwrContext** for ownership release.
35+ ffmpeg.SwrFree(unsafe.Pointer(swr))
36+}
37+
38+func freePacket(pkt *unsafe.Pointer) {
39+ //nolint:gosec // FFmpeg expects AVPacket** for ownership release.
40+ ffmpeg.PacketFree(unsafe.Pointer(pkt))
41+}
42+
43+func swrConvert(swr, outFrame, inFrame unsafe.Pointer, outSamples, inSamples int32) int32 {
44+ //nolint:gosec // FFmpeg expects raw AVFrame data pointers here.
45+ return ffmpeg.SwrConvert(
46+ swr,
47+ unsafe.Pointer((*unsafe.Pointer)(outFrame)), outSamples,
48+ unsafe.Pointer((*unsafe.Pointer)(inFrame)), inSamples,
49+ )
50+}
51+
52+// audioTranscoder decodes an input audio stream (typically AAC) and
53+// re-encodes it to Opus, performing sample rate conversion if needed.
54+type audioTranscoder struct {
55+ decoder unsafe.Pointer // AVCodecContext* for decoding
56+ encoder unsafe.Pointer // AVCodecContext* for Opus encoding
57+ resampler unsafe.Pointer // SwrContext* (nil if no resampling needed)
58+ frame unsafe.Pointer // reusable AVFrame for resampled output
59+ decFrame unsafe.Pointer // reusable AVFrame for decoded output
60+ encPkt unsafe.Pointer // reusable AVPacket for encoded output
61+ inStream int // input stream index in the source container
62+ outStream int // output stream index in the muxer
63+}
64+
65+// newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder.
66+// inCodecPar is the AVCodecParameters* from the input audio stream.
67+// outFmtCtx is the AVFormatContext* for the output container (used to
68+// add the output stream). inStreamIdx is the input stream index.
69+func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) {
70+ return newAudioTranscoderImpl(inCodecPar, outFmtCtx, inStreamIdx)
71+}
72+
73+//nolint:gocyclo,funlen // FFmpeg decoder/encoder/resampler setup needs the necessary branches.
74+func newAudioTranscoderImpl(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) {
75+ // --- Decoder setup ---
76+ codecID := ffmpeg.CodecParCodecID(inCodecPar)
77+ dec := ffmpeg.CodecFindDecoder(codecID)
78+ if dec == nil {
79+ return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID)
80+ }
81+
82+ decCtx := ffmpeg.CodecAllocContext3(dec)
83+ if decCtx == nil {
84+ return nil, errors.New("failed to allocate audio decoder context")
85+ }
86+
87+ if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 {
88+ freeCodecContext(&decCtx)
89+ return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret)
90+ }
91+
92+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
93+ freeCodecContext(&decCtx)
94+ return nil, fmt.Errorf("failed to open audio decoder: %d", ret)
95+ }
96+
97+ // --- Encoder setup (libopus) ---
98+ enc := ffmpeg.CodecFindEncoderByName("libopus")
99+ if enc == nil {
100+ freeCodecContext(&decCtx)
101+ return nil, errors.New("libopus encoder not found")
102+ }
103+
104+ encCtx := ffmpeg.CodecAllocContext3(enc)
105+ if encCtx == nil {
106+ freeCodecContext(&decCtx)
107+ return nil, errors.New("failed to allocate audio encoder context")
108+ }
109+
110+ // libopus accepts s16 (interleaved) and flt (float interleaved).
111+ // Planar formats (fltp) are NOT supported and cause encoder open to fail.
112+ ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt))
113+ ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate)
114+ codecCtxSetBitRate(encCtx, opusBitrate)
115+
116+ // Copy channel layout from decoder to encoder.
117+ codecCtxCopyChLayout(encCtx, decCtx)
118+
119+ // Set time base to 1/sample_rate for audio.
120+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate})
121+
122+ // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus.
123+
124+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
125+ freeCodecContext(&encCtx)
126+ freeCodecContext(&decCtx)
127+ return nil, fmt.Errorf("failed to open audio encoder: %d", ret)
128+ }
129+
130+ // --- Add output stream ---
131+ outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil)
132+ if outStreamPtr == nil {
133+ freeCodecContext(&encCtx)
134+ freeCodecContext(&decCtx)
135+ return nil, errors.New("failed to create output audio stream")
136+ }
137+
138+ outStreamWrap := ffmpeg.WrapStream(outStreamPtr)
139+ outStreamIdx := int(outStreamWrap.Index())
140+
141+ // Copy encoder parameters to the output stream's codecpar.
142+ if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 {
143+ freeCodecContext(&encCtx)
144+ freeCodecContext(&decCtx)
145+ return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret)
146+ }
147+
148+ // --- Resampler setup (if sample rates differ) ---
149+ var swr unsafe.Pointer
150+ var resampledFrame unsafe.Pointer
151+
152+ inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx)
153+ inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx)
154+ needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt)
155+
156+ if needResample {
157+ swr = ffmpeg.SwrAlloc()
158+ if swr == nil {
159+ freeCodecContext(&encCtx)
160+ freeCodecContext(&decCtx)
161+ return nil, errors.New("failed to allocate resampler")
162+ }
163+
164+ // Configure resampler via av_opt_set on the SwrContext.
165+ ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0)
166+ ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0)
167+ ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0)
168+ ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0)
169+
170+ // Modern FFmpeg (8.x) requires explicit channel layout configuration
171+ // on the SwrContext. Without it, swr_init may succeed but the resampler
172+ // operates with 0 channels and produces silence. Copy the decoder's
173+ // channel layout to both in and out (we keep them identical — same
174+ // number of channels, only sample rate/format changes).
175+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout)
176+ swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout)
177+
178+ if ret := ffmpeg.SwrInit(swr); ret < 0 {
179+ freeSwrContext(&swr)
180+ freeCodecContext(&encCtx)
181+ freeCodecContext(&decCtx)
182+ return nil, fmt.Errorf("failed to init resampler: %d", ret)
183+ }
184+
185+ // Allocate a reusable output frame for resampled data.
186+ resampledFrame = ffmpeg.FrameAlloc()
187+ if resampledFrame == nil {
188+ freeSwrContext(&swr)
189+ freeCodecContext(&encCtx)
190+ freeCodecContext(&decCtx)
191+ return nil, errors.New("failed to allocate resampled frame")
192+ }
193+
194+ frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt))
195+ frameSetSampleRate(resampledFrame, opusSampleRate)
196+ frameSetNbSamples(resampledFrame, opusFrameSize)
197+ // Copy channel layout from decoder to the resampled frame.
198+ codecCtxCopyChLayout(resampledFrame, decCtx)
199+ }
200+
201+ decFrame := ffmpeg.FrameAlloc()
202+ if decFrame == nil {
203+ if resampledFrame != nil {
204+ freeFrame(&resampledFrame)
205+ }
206+ if swr != nil {
207+ freeSwrContext(&swr)
208+ }
209+ freeCodecContext(&encCtx)
210+ freeCodecContext(&decCtx)
211+ return nil, errors.New("failed to allocate audio decode frame")
212+ }
213+
214+ encPkt := ffmpeg.PacketAlloc()
215+ if encPkt == nil {
216+ freeFrame(&decFrame)
217+ if resampledFrame != nil {
218+ freeFrame(&resampledFrame)
219+ }
220+ if swr != nil {
221+ freeSwrContext(&swr)
222+ }
223+ freeCodecContext(&encCtx)
224+ freeCodecContext(&decCtx)
225+ return nil, errors.New("failed to allocate audio encode packet")
226+ }
227+
228+ return &audioTranscoder{
229+ decoder: decCtx,
230+ encoder: encCtx,
231+ resampler: swr,
232+ frame: resampledFrame,
233+ decFrame: decFrame,
234+ encPkt: encPkt,
235+ inStream: inStreamIdx,
236+ outStream: outStreamIdx,
237+ }, nil
238+}
239+
240+// processPacket decodes an audio packet, optionally resamples, encodes
241+// to Opus, and writes the resulting packets to outFmtCtx.
242+func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
243+ // Send packet to decoder.
244+ sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt)
245+ if sendRet < 0 {
246+ return fmt.Errorf("audio decode send packet: %d", sendRet)
247+ }
248+
249+ frame := a.decFrame
250+ encPkt := a.encPkt
251+
252+ for {
253+ recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame)
254+ if recvRet < 0 {
255+ // EAGAIN or EOF — no more frames from this packet.
256+ break
257+ }
258+
259+ // Determine which frame to send to the encoder.
260+ encFrame := frame
261+ if a.resampler != nil {
262+ encFrame = a.frame
263+
264+ inSamples := frameNbSamples(frame)
265+
266+ // Allocate buffer for output frame if needed.
267+ frameSetNbSamples(a.frame, opusFrameSize)
268+ if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 {
269+ ffmpeg.FrameUnref(frame)
270+ return fmt.Errorf("failed to allocate resampled frame buffer: %d", bufferRet)
271+ }
272+
273+ converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples)
274+ if converted < 0 {
275+ ffmpeg.FrameUnref(frame)
276+ return fmt.Errorf("audio resample failed: %d", converted)
277+ }
278+ frameSetNbSamples(a.frame, converted)
279+
280+ // Copy pts from decoded frame, rescaling for the new sample rate.
281+ pts := framePts(frame)
282+ if pts >= 0 {
283+ decTB := ffmpeg.CodecCtxTimeBase(a.decoder)
284+ encTB := ffmpeg.CodecCtxTimeBase(a.encoder)
285+ frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB))
286+ }
287+ }
288+
289+ // Send frame to encoder.
290+ if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 {
291+ ffmpeg.FrameUnref(frame)
292+ return fmt.Errorf("audio encode send frame: %d", sendFrameRet)
293+ }
294+
295+ // Receive encoded packets.
296+ for {
297+ recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
298+ if recvPktRet < 0 {
299+ break
300+ }
301+
302+ pktSetStreamIndex(encPkt, int32(a.outStream))
303+
304+ // Rescale timestamps from encoder time base to output stream time base.
305+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
306+ outStreamWrap := ffmpeg.WrapStream(outStream)
307+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
308+
309+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
310+ ffmpeg.PacketUnref(encPkt)
311+ ffmpeg.FrameUnref(frame)
312+ return fmt.Errorf("audio write frame: %d", wret)
313+ }
314+ ffmpeg.PacketUnref(encPkt)
315+ }
316+
317+ ffmpeg.FrameUnref(frame)
318+ }
319+
320+ return nil
321+}
322+
323+// flush drains remaining frames from the decoder and encoder.
324+// The logger parameter is used to report non-fatal resampling errors
325+// that occur during flush (we continue flushing rather than aborting).
326+func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) {
327+ // Flush decoder by sending NULL packet.
328+ ffmpeg.CodecSendPacket(a.decoder, nil)
329+
330+ frame := a.decFrame
331+ encPkt := a.encPkt
332+
333+ // Drain decoded frames.
334+ for {
335+ recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame)
336+ if recvRet < 0 {
337+ break
338+ }
339+
340+ encFrame := frame
341+ if a.resampler != nil {
342+ encFrame = a.frame
343+ inSamples := frameNbSamples(frame)
344+
345+ frameSetNbSamples(a.frame, opusFrameSize)
346+ if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 {
347+ logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush")
348+ ffmpeg.FrameUnref(frame)
349+ continue
350+ }
351+
352+ converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples)
353+ if converted < 0 {
354+ logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush")
355+ ffmpeg.FrameUnref(frame)
356+ continue
357+ }
358+ frameSetNbSamples(a.frame, converted)
359+ }
360+
361+ ffmpeg.CodecSendFrame(a.encoder, encFrame)
362+
363+ for {
364+ recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
365+ if recvPktRet < 0 {
366+ break
367+ }
368+ pktSetStreamIndex(encPkt, int32(a.outStream))
369+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
370+ outStreamWrap := ffmpeg.WrapStream(outStream)
371+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
372+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
373+ ffmpeg.PacketUnref(encPkt)
374+ }
375+
376+ ffmpeg.FrameUnref(frame)
377+ }
378+
379+ // Flush encoder by sending NULL frame.
380+ ffmpeg.CodecSendFrame(a.encoder, nil)
381+ for {
382+ flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
383+ if flushPktRet < 0 {
384+ break
385+ }
386+ pktSetStreamIndex(encPkt, int32(a.outStream))
387+ outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream)
388+ outStreamWrap := ffmpeg.WrapStream(outStream)
389+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase())
390+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
391+ ffmpeg.PacketUnref(encPkt)
392+ }
393+}
394+
395+// close frees all FFmpeg resources owned by the audio transcoder.
396+func (a *audioTranscoder) close() {
397+ if a.encPkt != nil {
398+ freePacket(&a.encPkt)
399+ }
400+ if a.decFrame != nil {
401+ freeFrame(&a.decFrame)
402+ }
403+ if a.frame != nil {
404+ freeFrame(&a.frame)
405+ }
406+ if a.resampler != nil {
407+ freeSwrContext(&a.resampler)
408+ }
409+ if a.encoder != nil {
410+ freeCodecContext(&a.encoder)
411+ }
412+ if a.decoder != nil {
413+ freeCodecContext(&a.decoder)
414+ }
415+}
A internal/infrastructure/transcoder/detect.go +180 -0
@@ -0,0 +1,180 @@
1+package transcoder
2+
3+import (
4+ "mime"
5+ "net/url"
6+ "path"
7+ "strings"
8+)
9+
10+// proprietaryVideoMIMEs lists MIME types for containers that typically carry
11+// proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to
12+// open formats (VP8/VP9/AV1 + Opus) for playback in browsers without
13+// proprietary codec support.
14+var proprietaryVideoMIMEs = map[string]bool{
15+ "video/mp4": true, // H.264/HEVC + AAC
16+ "video/x-flv": true, // Flash Video (H.264 or Sorenson)
17+ "video/3gpp": true, // 3GPP (H.264 + AAC)
18+ "video/3gpp2": true, // 3GPP2 (H.264 + AAC)
19+ "video/mp2t": true, // MPEG-TS (usually H.264)
20+ "video/mpeg": true, // MPEG-1/2
21+ "video/x-msvideo": true, // AVI (often H.264)
22+ "video/x-matroska": true, // MKV (often H.264/HEVC)
23+ "video/quicktime": true, // MOV (H.264 + AAC)
24+ "video/x-m4v": true, // M4V (H.264 + AAC)
25+}
26+
27+// openVideoMIMEs lists MIME types for containers carrying open codecs
28+// (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding.
29+var openVideoMIMEs = map[string]bool{
30+ "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis
31+ "video/ogg": true, // Theora + Vorbis
32+}
33+
34+// streamingManifestMIMEs lists manifest/container MIME types that usually
35+// resolve to proprietary segment streams via HLS or DASH.
36+var streamingManifestMIMEs = map[string]bool{
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
41+ "application/dash+xml": true,
42+}
43+
44+var proprietaryVideoExtensions = map[string]bool{
45+ ".mp4": true,
46+ ".m4v": true,
47+ ".mov": true,
48+ ".mkv": true,
49+ ".avi": true,
50+ ".flv": true,
51+ ".3gp": true,
52+ ".3g2": true,
53+ ".mpeg": true,
54+ ".mpg": true,
55+}
56+
57+const syntheticTranscodePath = "/__dumber__/transcode.webm"
58+
59+// IsProprietaryVideoMIME returns true if the MIME type indicates a video
60+// format that typically carries proprietary codecs requiring transcoding.
61+func IsProprietaryVideoMIME(contentType string) bool {
62+ mediaType := parseMediaType(contentType)
63+ return proprietaryVideoMIMEs[mediaType]
64+}
65+
66+// IsOpenVideoMIME returns true if the MIME type indicates a video format
67+// with open codecs that do not require transcoding.
68+func IsOpenVideoMIME(contentType string) bool {
69+ mediaType := parseMediaType(contentType)
70+ return openVideoMIMEs[mediaType]
71+}
72+
73+// IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types.
74+func IsStreamingManifestMIME(contentType string) bool {
75+ mediaType := parseMediaType(contentType)
76+ return streamingManifestMIMEs[mediaType]
77+}
78+
79+// IsStreamingManifestURL returns true for URLs whose path indicates an HLS or
80+// DASH manifest entrypoint.
81+func IsStreamingManifestURL(rawURL string) bool {
82+ if rawURL == "" {
83+ return false
84+ }
85+
86+ parsed, err := url.Parse(rawURL)
87+ if err != nil {
88+ return hasManifestExtension(rawURL)
89+ }
90+
91+ return hasManifestExtension(parsed.Path)
92+}
93+
94+// IsEagerTranscodeURL returns true for URLs that can be identified as
95+// transcodable before any response MIME is received.
96+func IsEagerTranscodeURL(rawURL string) bool {
97+ if rawURL == "" {
98+ return false
99+ }
100+
101+ if IsSyntheticTranscodeURL(rawURL) {
102+ return true
103+ }
104+
105+ parsed, err := url.Parse(rawURL)
106+ if err != nil {
107+ return hasEagerTranscodeExtension(rawURL)
108+ }
109+
110+ return hasEagerTranscodeExtension(parsed.Path)
111+}
112+
113+// IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs
114+// that should be intercepted and resolved to an underlying source URL.
115+func IsSyntheticTranscodeURL(rawURL string) bool {
116+ sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL)
117+ _ = sourceURL
118+ _ = referer
119+ _ = origin
120+ return ok
121+}
122+
123+// ParseSyntheticTranscodeURL extracts the underlying source URL and optional
124+// forwarded headers from a synthetic transcode URL.
125+func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) {
126+ if rawURL == "" {
127+ return "", "", "", false
128+ }
129+
130+ parsed, err := url.Parse(rawURL)
131+ if err != nil {
132+ return "", "", "", false
133+ }
134+ if parsed.Path != syntheticTranscodePath {
135+ return "", "", "", false
136+ }
137+
138+ sourceURL = strings.TrimSpace(parsed.Query().Get("src"))
139+ if sourceURL == "" {
140+ return "", "", "", false
141+ }
142+ sourceParsed, err := url.Parse(sourceURL)
143+ if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
144+ return "", "", "", false
145+ }
146+
147+ referer = strings.TrimSpace(parsed.Query().Get("referer"))
148+ origin = strings.TrimSpace(parsed.Query().Get("origin"))
149+ return sourceURL, referer, origin, true
150+}
151+
152+func hasManifestExtension(value string) bool {
153+ ext := strings.ToLower(path.Ext(value))
154+ return ext == ".m3u8" || ext == ".mpd"
155+}
156+
157+func hasEagerTranscodeExtension(value string) bool {
158+ ext := strings.ToLower(path.Ext(value))
159+ return hasManifestExtension(value) || proprietaryVideoExtensions[ext]
160+}
161+
162+// parseMediaType extracts the MIME type from a Content-Type header value,
163+// stripping parameters like charset or codecs.
164+func parseMediaType(contentType string) string {
165+ if contentType == "" {
166+ return ""
167+ }
168+ // mime.ParseMediaType handles the full RFC 2045 parsing, but is
169+ // overkill and can fail on non-conforming values. A simple split
170+ // on ";" is more resilient for our needs.
171+ mediaType, _, _ := mime.ParseMediaType(contentType)
172+ if mediaType != "" {
173+ return strings.ToLower(mediaType)
174+ }
175+ // Fallback: trim parameters manually
176+ if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
177+ contentType = contentType[:idx]
178+ }
179+ return strings.ToLower(strings.TrimSpace(contentType))
180+}
A internal/infrastructure/transcoder/detect_test.go +254 -0
@@ -0,0 +1,254 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsProprietaryVideoMIME(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ contentType string
9+ want bool
10+ }{
11+ // Proprietary formats
12+ {name: "mp4", contentType: "video/mp4", want: true},
13+ {name: "mp4 with codecs param", contentType: "video/mp4; codecs=\"avc1.42E01E\"", want: true},
14+ {name: "mp4 with charset", contentType: "video/mp4; charset=utf-8", want: true},
15+ {name: "flv", contentType: "video/x-flv", want: true},
16+ {name: "3gpp", contentType: "video/3gpp", want: true},
17+ {name: "3gpp2", contentType: "video/3gpp2", want: true},
18+ {name: "mpeg-ts", contentType: "video/mp2t", want: true},
19+ {name: "mpeg", contentType: "video/mpeg", want: true},
20+ {name: "avi", contentType: "video/x-msvideo", want: true},
21+ {name: "mkv", contentType: "video/x-matroska", want: true},
22+ {name: "quicktime", contentType: "video/quicktime", want: true},
23+ {name: "m4v", contentType: "video/x-m4v", want: true},
24+
25+ // Open formats (should be false)
26+ {name: "webm", contentType: "video/webm", want: false},
27+ {name: "ogg", contentType: "video/ogg", want: false},
28+
29+ // Non-video (should be false)
30+ {name: "audio mp3", contentType: "audio/mpeg", want: false},
31+ {name: "text html", contentType: "text/html", want: false},
32+ {name: "application json", contentType: "application/json", want: false},
33+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
34+ {name: "empty", contentType: "", want: false},
35+
36+ // Case insensitivity
37+ {name: "mp4 uppercase", contentType: "Video/MP4", want: true},
38+ {name: "webm uppercase", contentType: "Video/WebM", want: false},
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ got := IsProprietaryVideoMIME(tt.contentType)
44+ if got != tt.want {
45+ t.Errorf("IsProprietaryVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestIsOpenVideoMIME(t *testing.T) {
52+ tests := []struct {
53+ name string
54+ contentType string
55+ want bool
56+ }{
57+ // Open formats
58+ {name: "webm", contentType: "video/webm", want: true},
59+ {name: "webm with codecs", contentType: "video/webm; codecs=\"vp9,opus\"", want: true},
60+ {name: "ogg", contentType: "video/ogg", want: true},
61+
62+ // Proprietary formats (should be false)
63+ {name: "mp4", contentType: "video/mp4", want: false},
64+ {name: "flv", contentType: "video/x-flv", want: false},
65+ {name: "mkv", contentType: "video/x-matroska", want: false},
66+
67+ // Non-video (should be false)
68+ {name: "audio ogg", contentType: "audio/ogg", want: false},
69+ {name: "text html", contentType: "text/html", want: false},
70+ {name: "octet-stream", contentType: "application/octet-stream", want: false},
71+ {name: "empty", contentType: "", want: false},
72+
73+ // Case insensitivity
74+ {name: "webm uppercase", contentType: "Video/WebM", want: true},
75+ {name: "ogg uppercase", contentType: "VIDEO/OGG", want: true},
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ got := IsOpenVideoMIME(tt.contentType)
81+ if got != tt.want {
82+ t.Errorf("IsOpenVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
83+ }
84+ })
85+ }
86+}
87+
88+func TestIsStreamingManifestMIME(t *testing.T) {
89+ tests := []struct {
90+ name string
91+ contentType string
92+ want bool
93+ }{
94+ {name: "hls apple", contentType: "application/vnd.apple.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},
98+ {name: "dash", contentType: "application/dash+xml", want: true},
99+ {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true},
100+ {name: "mp4", contentType: "video/mp4", want: false},
101+ {name: "json", contentType: "application/json", want: false},
102+ {name: "empty", contentType: "", want: false},
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ got := IsStreamingManifestMIME(tt.contentType)
108+ if got != tt.want {
109+ t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want)
110+ }
111+ })
112+ }
113+}
114+
115+func TestIsStreamingManifestURL(t *testing.T) {
116+ tests := []struct {
117+ name string
118+ rawURL string
119+ want bool
120+ }{
121+ {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true},
122+ {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true},
123+ {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true},
124+ {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false},
125+ {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false},
126+ {name: "empty", rawURL: "", want: false},
127+ {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true},
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ got := IsStreamingManifestURL(tt.rawURL)
133+ if got != tt.want {
134+ t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want)
135+ }
136+ })
137+ }
138+}
139+
140+func TestIsEagerTranscodeURL(t *testing.T) {
141+ tests := []struct {
142+ name string
143+ rawURL string
144+ want bool
145+ }{
146+ {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true},
147+ {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true},
148+ {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true},
149+ {name: "mov file", rawURL: "https://example.com/video.mov", want: true},
150+ {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true},
151+ {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false},
152+ {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false},
153+ {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true},
154+ {name: "empty", rawURL: "", want: false},
155+ }
156+
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ got := IsEagerTranscodeURL(tt.rawURL)
160+ if got != tt.want {
161+ t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want)
162+ }
163+ })
164+ }
165+}
166+
167+func TestParseSyntheticTranscodeURL(t *testing.T) {
168+ tests := []struct {
169+ name string
170+ rawURL string
171+ wantSrc string
172+ wantRef string
173+ wantOrig string
174+ wantOK bool
175+ }{
176+ {
177+ name: "valid with all params",
178+ rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com",
179+ wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8",
180+ wantRef: "https://www.reddit.com/r/OpenAI",
181+ wantOrig: "https://www.reddit.com",
182+ wantOK: true,
183+ },
184+ {
185+ name: "valid without referer and origin",
186+ rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
187+ wantSrc: "https://cdn.example.com/video.mp4",
188+ wantRef: "",
189+ wantOrig: "",
190+ wantOK: true,
191+ },
192+ {
193+ name: "empty URL",
194+ rawURL: "",
195+ wantOK: false,
196+ },
197+ {
198+ name: "missing src parameter",
199+ rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com",
200+ wantOK: false,
201+ },
202+ {
203+ name: "wrong path",
204+ rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
205+ wantOK: false,
206+ },
207+ {
208+ name: "src with no host",
209+ rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url",
210+ wantOK: false,
211+ },
212+ {
213+ name: "src with non-http scheme",
214+ rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4",
215+ wantOK: false,
216+ },
217+ {
218+ name: "src is empty string",
219+ rawURL: "https://example.com/__dumber__/transcode.webm?src=",
220+ wantOK: false,
221+ },
222+ {
223+ name: "src is whitespace",
224+ rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20",
225+ wantOK: false,
226+ },
227+ {
228+ name: "unparseable URL",
229+ rawURL: "://invalid",
230+ wantOK: false,
231+ },
232+ }
233+
234+ for _, tt := range tests {
235+ t.Run(tt.name, func(t *testing.T) {
236+ src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL)
237+ if ok != tt.wantOK {
238+ t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
239+ }
240+ if !tt.wantOK {
241+ return
242+ }
243+ if src != tt.wantSrc {
244+ t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc)
245+ }
246+ if ref != tt.wantRef {
247+ t.Errorf("referer = %q, want %q", ref, tt.wantRef)
248+ }
249+ if orig != tt.wantOrig {
250+ t.Errorf("origin = %q, want %q", orig, tt.wantOrig)
251+ }
252+ })
253+ }
254+}
A internal/infrastructure/transcoder/ffmpeg_offsets.go +164 -0
@@ -0,0 +1,164 @@
1+package transcoder
2+
3+// Offset-based accessors for FFmpeg struct fields not yet exposed
4+// by purego-ffmpeg.
5+//
6+// Validated against:
7+// FFmpeg 8.1 (n8.1)
8+// libavcodec 62.28.100
9+// libavformat 62.12.100
10+// libavutil 60.26.100
11+//
12+// All offsets verified via offsetof() on the installed headers.
13+
14+import "unsafe"
15+
16+// ---------------------------------------------------------------------------
17+// AVPacket field offsets (FFmpeg 8.x, libavcodec 62)
18+// Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16),
19+// uint8* data (24), int size (32), int stream_index (36), int flags (40)
20+// ---------------------------------------------------------------------------
21+
22+const (
23+ offsetPktStreamIndex = 36
24+)
25+
26+func pktStreamIndex(pkt unsafe.Pointer) int32 {
27+ return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex))
28+}
29+
30+func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) {
31+ *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx
32+}
33+
34+// ---------------------------------------------------------------------------
35+// AVFrame field offsets (FFmpeg 8.x, libavutil 60)
36+// Layout (relevant fields):
37+// data[8] at offset 0 (8 pointers = 64 bytes)
38+// linesize[8] at offset 64 (8 x int32 = 32 bytes)
39+// ...
40+// width at offset 104
41+// height at offset 108
42+// nb_samples at offset 112
43+// format at offset 116
44+// pts at offset 136
45+// sample_rate at offset 180
46+// hw_frames_ctx at offset 328 (AVBufferRef*)
47+// ch_layout at offset 384 (AVChannelLayout, 24 bytes)
48+// ---------------------------------------------------------------------------
49+
50+const (
51+ offsetFrameWidth = 104
52+ offsetFrameHeight = 108
53+ offsetFrameNbSamples = 112
54+ offsetFrameFormat = 116
55+ offsetFrameHWFramesCtx = 328
56+ offsetFramePts = 136
57+ offsetFrameSampleRate = 180
58+)
59+
60+func frameWidth(f unsafe.Pointer) int32 {
61+ return *(*int32)(unsafe.Add(f, offsetFrameWidth))
62+}
63+
64+func frameSetWidth(f unsafe.Pointer, v int32) {
65+ *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v
66+}
67+
68+func frameHeight(f unsafe.Pointer) int32 {
69+ return *(*int32)(unsafe.Add(f, offsetFrameHeight))
70+}
71+
72+func frameSetHeight(f unsafe.Pointer, v int32) {
73+ *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v
74+}
75+
76+func frameNbSamples(f unsafe.Pointer) int32 {
77+ return *(*int32)(unsafe.Add(f, offsetFrameNbSamples))
78+}
79+
80+func frameSetNbSamples(f unsafe.Pointer, v int32) {
81+ *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v
82+}
83+
84+func frameFormat(f unsafe.Pointer) int32 {
85+ return *(*int32)(unsafe.Add(f, offsetFrameFormat))
86+}
87+
88+func frameSetFormat(f unsafe.Pointer, v int32) {
89+ *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v
90+}
91+
92+func frameSetHWFramesCtx(f, v unsafe.Pointer) {
93+ *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v
94+}
95+
96+func framePts(f unsafe.Pointer) int64 {
97+ return *(*int64)(unsafe.Add(f, offsetFramePts))
98+}
99+
100+func frameSetPts(f unsafe.Pointer, v int64) {
101+ *(*int64)(unsafe.Add(f, offsetFramePts)) = v
102+}
103+
104+func frameSetSampleRate(f unsafe.Pointer, v int32) {
105+ *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v
106+}
107+
108+// ---------------------------------------------------------------------------
109+// AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62)
110+// ---------------------------------------------------------------------------
111+
112+const (
113+ offsetCodecCtxGopSize = 332
114+ offsetCodecCtxBitRate = 56
115+ offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes)
116+)
117+
118+func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) {
119+ *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v
120+}
121+
122+func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) {
123+ *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v
124+}
125+
126+// chLayoutCopy copies the 24-byte AVChannelLayout from src to dst.
127+// Both must point to the ch_layout field within their respective AVCodecContext.
128+//
129+//nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers.
130+func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
131+ dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout)
132+ srcLayout := unsafe.Add(src, offsetCodecCtxChLayout)
133+ // AVChannelLayout is 24 bytes in FFmpeg 8.x.
134+ copy(
135+ unsafe.Slice((*byte)(dstLayout), channelLayoutSize),
136+ unsafe.Slice((*byte)(srcLayout), channelLayoutSize),
137+ )
138+}
139+
140+// ---------------------------------------------------------------------------
141+// SwrContext channel layout offsets (FFmpeg 8.x, libswresample)
142+// SwrContext is opaque; offsets verified by writing known AVChannelLayout
143+// values via av_opt_set_chlayout and scanning the resulting memory.
144+// ---------------------------------------------------------------------------
145+
146+const (
147+ channelLayoutSize = 24
148+
149+ offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes)
150+ offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes)
151+)
152+
153+// swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from
154+// an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout.
155+//
156+//nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers.
157+func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) {
158+ dst := unsafe.Add(swr, swrOffset)
159+ src := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
160+ copy(
161+ unsafe.Slice((*byte)(dst), channelLayoutSize),
162+ unsafe.Slice((*byte)(src), channelLayoutSize),
163+ )
164+}
A internal/infrastructure/transcoder/hwprobe.go +128 -0
@@ -0,0 +1,128 @@
1+package transcoder
2+
3+import (
4+ "github.com/bnema/purego-ffmpeg/ffmpeg"
5+ "github.com/rs/zerolog"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// usableEncoders lists hardware encoders that output open codecs
11+// CEF can decode natively (VP9, AV1). H.264/HEVC encoders exist
12+// but their output cannot be played by CEF's minimal build.
13+var usableEncoders = map[string]bool{
14+ "av1_vaapi": true,
15+ "vp9_vaapi": true,
16+ "av1_nvenc": true,
17+}
18+
19+const hwAPINameVAAPI = "vaapi"
20+
21+// isUsableEncoder returns true only for hardware encoders whose output
22+// codec is open and decodable by CEF (AV1, VP9).
23+func isUsableEncoder(name string) bool {
24+ return usableEncoders[name]
25+}
26+
27+// hwProfile groups the encoder/decoder names to probe for a given API.
28+type hwProfile struct {
29+ api string // "vaapi" or "nvenc"
30+ deviceType int32
31+ encoders []string
32+ decoders []string
33+}
34+
35+var vaAPIProfile = hwProfile{
36+ api: hwAPINameVAAPI,
37+ deviceType: int32(ffmpeg.HwdeviceTypeVaapi),
38+ encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"},
39+ decoders: []string{"h264_vaapi", "hevc_vaapi"},
40+}
41+
42+var nvencProfile = hwProfile{
43+ api: "nvenc",
44+ deviceType: int32(ffmpeg.HwdeviceTypeCuda),
45+ // hevc_nvenc and h264_nvenc are probed for logging/diagnostic purposes
46+ // but filtered as non-usable by isUsableEncoder (only open codecs like
47+ // AV1 and VP9 are usable in CEF's minimal build).
48+ encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"},
49+ decoders: []string{"h264_cuvid", "hevc_cuvid"},
50+}
51+
52+// ProbeGPU detects available GPU hardware encoding capabilities.
53+// hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA),
54+// "vaapi" (VAAPI only), or "nvenc" (CUDA only).
55+// Returns empty HWCapabilities if no compatible GPU encoder is found.
56+func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities {
57+ if err := ffmpeg.Init(); err != nil {
58+ logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped")
59+ return port.HWCapabilities{}
60+ }
61+
62+ var profiles []hwProfile
63+ switch hwaccelPref {
64+ case hwAPINameVAAPI:
65+ profiles = []hwProfile{vaAPIProfile}
66+ case "nvenc":
67+ profiles = []hwProfile{nvencProfile}
68+ default: // "auto" or unrecognized
69+ profiles = []hwProfile{vaAPIProfile, nvencProfile}
70+ }
71+
72+ for _, p := range profiles {
73+ caps := probeProfile(p, logger)
74+ if len(caps.Encoders) > 0 {
75+ return caps
76+ }
77+ }
78+
79+ logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable")
80+ return port.HWCapabilities{}
81+}
82+
83+// probeProfile attempts to open a hardware device and enumerate the
84+// available encoders/decoders for a single API profile.
85+func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities {
86+ deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "")
87+ if err != nil {
88+ logger.Debug().
89+ Str("api", p.api).
90+ Err(err).
91+ Msg("hardware device not available")
92+ return port.HWCapabilities{}
93+ }
94+ // Free the probing device context; a long-lived one will be
95+ // created per transcode session (or shared across sessions).
96+ defer ffmpeg.BufferUnref(&deviceCtx)
97+
98+ logger.Info().
99+ Str("api", p.api).
100+ Msg("hardware device opened for probing")
101+
102+ var encoders []string
103+ for _, name := range p.encoders {
104+ if ffmpeg.CodecFindEncoderByName(name) != nil {
105+ if isUsableEncoder(name) {
106+ encoders = append(encoders, name)
107+ logger.Info().Str("encoder", name).Msg("usable GPU encoder found")
108+ } else {
109+ logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)")
110+ }
111+ }
112+ }
113+
114+ var decoders []string
115+ for _, name := range p.decoders {
116+ if ffmpeg.CodecFindDecoderByName(name) != nil {
117+ decoders = append(decoders, name)
118+ logger.Debug().Str("decoder", name).Msg("GPU decoder found")
119+ }
120+ }
121+
122+ return port.HWCapabilities{
123+ API: p.api,
124+ Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning
125+ Encoders: encoders,
126+ Decoders: decoders,
127+ }
128+}
A internal/infrastructure/transcoder/hwprobe_test.go +39 -0
@@ -0,0 +1,39 @@
1+package transcoder
2+
3+import "testing"
4+
5+func TestIsUsableEncoder(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ encoder string
9+ want bool
10+ }{
11+ // Usable: open codec output that CEF can decode
12+ {name: "av1_vaapi", encoder: "av1_vaapi", want: true},
13+ {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true},
14+ {name: "av1_nvenc", encoder: "av1_nvenc", want: true},
15+
16+ // Not usable: proprietary codec output CEF cannot decode
17+ {name: "h264_vaapi", encoder: "h264_vaapi", want: false},
18+ {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false},
19+ {name: "h264_nvenc", encoder: "h264_nvenc", want: false},
20+
21+ // Software encoders are not usable either
22+ {name: "libx264", encoder: "libx264", want: false},
23+ {name: "libsvtav1", encoder: "libsvtav1", want: false},
24+ {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false},
25+
26+ // Edge cases
27+ {name: "empty string", encoder: "", want: false},
28+ {name: "random string", encoder: "not_a_codec", want: false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ got := isUsableEncoder(tt.encoder)
34+ if got != tt.want {
35+ t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want)
36+ }
37+ })
38+ }
39+}
A internal/infrastructure/transcoder/pipeline.go +918 -0
@@ -0,0 +1,918 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "sort"
10+ "strings"
11+ "unsafe"
12+
13+ "net"
14+ "time"
15+
16+ "github.com/ebitengine/purego"
17+ "github.com/rs/zerolog"
18+
19+ "github.com/bnema/purego-ffmpeg/ffmpeg"
20+
21+ "github.com/bnema/dumber/internal/application/port"
22+)
23+
24+// avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
25+const avioBufSize = 32 * 1024
26+
27+const (
28+ vaapiInitialPoolSize = 20
29+ minVideoBitrate = 500_000
30+)
31+
32+// pipeline orchestrates a single GPU transcode session. It reads from
33+// an HTTP source, decodes with a hardware decoder (or software fallback),
34+// encodes video to an open codec (AV1/VP9) via GPU, encodes audio to
35+// Opus via libopus, and muxes the result into WebM written to an
36+// io.PipeWriter.
37+type pipeline struct {
38+ sessionID string
39+ hwCaps port.HWCapabilities
40+ sourceURL string
41+ headers map[string]string
42+ quality string
43+ pw *io.PipeWriter
44+ logger zerolog.Logger
45+
46+ // Prevent GC of callback closures and uintptrs.
47+ readCb uintptr
48+ writeCb uintptr
49+}
50+
51+// httpClient is used for fetching source media. It has connect/TLS/header
52+// timeouts but no overall Timeout so that long-lived streaming responses
53+// are not killed. The session context handles cancellation.
54+var httpClient = &http.Client{
55+ Transport: &http.Transport{
56+ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
57+ TLSHandshakeTimeout: 10 * time.Second,
58+ ResponseHeaderTimeout: 15 * time.Second,
59+ },
60+}
61+
62+func newPipeline(
63+ sessionID string,
64+ hwCaps port.HWCapabilities,
65+ sourceURL string,
66+ headers map[string]string,
67+ quality string,
68+ pw *io.PipeWriter,
69+ logger zerolog.Logger,
70+) *pipeline {
71+ return &pipeline{
72+ sessionID: sessionID,
73+ hwCaps: hwCaps,
74+ sourceURL: sourceURL,
75+ headers: headers,
76+ quality: quality,
77+ pw: pw,
78+ logger: logger,
79+ }
80+}
81+
82+// run executes the full transcode pipeline in the calling goroutine.
83+// It closes pw when done (with error or nil).
84+func (p *pipeline) run(ctx context.Context) {
85+ err := p.doRun(ctx)
86+ if err != nil {
87+ log := p.logger.With().
88+ Str("session_id", p.sessionID).
89+ Str("source_url", p.sourceURL).
90+ Logger()
91+ if errors.Is(err, context.Canceled) {
92+ log.Debug().Err(err).Msg("transcode pipeline canceled")
93+ } else {
94+ log.Error().Err(err).Msg("transcode pipeline failed")
95+ }
96+ _ = p.pw.CloseWithError(err)
97+ } else {
98+ p.logger.Debug().
99+ Str("session_id", p.sessionID).
100+ Str("source_url", p.sourceURL).
101+ Msg("transcode pipeline completed")
102+ _ = p.pw.Close()
103+ }
104+}
105+
106+// doRun contains the actual pipeline logic. Errors are returned, and
107+// the caller (run) closes the pipe writer accordingly.
108+func (p *pipeline) doRun(ctx context.Context) error {
109+ return p.doRunImpl(ctx)
110+}
111+
112+//nolint:gocyclo,funlen,gosec // FFmpeg pipeline setup/teardown uses necessary branching and unsafe C-API calls.
113+func (p *pipeline) doRunImpl(ctx context.Context) error {
114+ // --- Step 1-3: Open the input using the appropriate strategy ---
115+ inFmtCtx, closeInput, err := p.openInputFormatContext(ctx)
116+ if err != nil {
117+ return err
118+ }
119+ defer closeInput()
120+
121+ // --- Step 4: Find stream info ---
122+ if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 {
123+ return fmt.Errorf("find stream info: %d", ret)
124+ }
125+
126+ // --- Step 5: Select input video/audio streams ---
127+ videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx)
128+ if err != nil {
129+ return err
130+ }
131+
132+ videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)
133+ videoStreamWrap := ffmpeg.WrapStream(videoStream)
134+ videoCodecPar := videoStreamWrap.Codecpar()
135+
136+ // --- Step 6: Open video decoder ---
137+ vidDecCtx, err := p.openVideoDecoder(videoCodecPar)
138+ if err != nil {
139+ return fmt.Errorf("open video decoder: %w", err)
140+ }
141+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
142+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx))
143+
144+ // --- Step 7-8: Open video encoder with HW accel ---
145+ hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx)
146+ if err != nil {
147+ return fmt.Errorf("open video encoder: %w", err)
148+ }
149+ if hwDeviceCtx != nil {
150+ defer ffmpeg.BufferUnref(&hwDeviceCtx)
151+ }
152+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
153+ defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
154+
155+ // --- Step 9: Create output format context for WebM ---
156+ var outFmtCtx unsafe.Pointer
157+ //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts.
158+ if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 {
159+ return fmt.Errorf("alloc output context: %d", ret)
160+ }
161+ defer ffmpeg.FormatFreeContext(outFmtCtx)
162+
163+ // --- Step 10: Create write AVIO callback ---
164+ pw := p.pw
165+ writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
166+ if bufSize <= 0 {
167+ return 0
168+ }
169+ //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here.
170+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
171+ n, writeErr := pw.Write(goBuf)
172+ if writeErr != nil {
173+ return -1
174+ }
175+ return int32(n)
176+ }
177+ p.writeCb = purego.NewCallback(writeFn)
178+
179+ writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0)
180+ if writeAVIO == nil {
181+ return errors.New("failed to allocate write AVIO context")
182+ }
183+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
184+ defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO))
185+
186+ ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO)
187+ ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
188+
189+ // --- Step 11: Add video output stream ---
190+ outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil)
191+ if outVideoStream == nil {
192+ return errors.New("failed to create output video stream")
193+ }
194+ outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream)
195+ outVideoIdx := int(outVideoStreamWrap.Index())
196+
197+ if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 {
198+ return fmt.Errorf("copy video encoder params to stream: %d", ret)
199+ }
200+
201+ // --- Step 11b: Add audio output stream (if audio present) ---
202+ var audioTx *audioTranscoder
203+ if audioIdx >= 0 {
204+ audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)
205+ audioStreamWrap := ffmpeg.WrapStream(audioStream)
206+ audioCodecPar := audioStreamWrap.Codecpar()
207+
208+ var txErr error
209+ audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx)
210+ if txErr != nil {
211+ // Audio transcoding failure is non-fatal; we proceed with video only.
212+ audioTx = nil
213+ audioIdx = -1
214+ }
215+ }
216+ // Use a closure so the defer sees the current value of audioTx at
217+ // cleanup time. This allows us to close and nil-out audioTx mid-loop
218+ // (e.g., on audio processing error) without double-closing.
219+ defer func() {
220+ if audioTx != nil {
221+ audioTx.close()
222+ }
223+ }()
224+
225+ // --- Step 12: Write header ---
226+ if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 {
227+ return fmt.Errorf("write header: %d", ret)
228+ }
229+
230+ // --- Step 13: Main decode/encode loop ---
231+ pkt := ffmpeg.PacketAlloc()
232+ if pkt == nil {
233+ return errors.New("failed to allocate read packet")
234+ }
235+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations.
236+ defer ffmpeg.PacketFree(unsafe.Pointer(&pkt))
237+
238+ frame := ffmpeg.FrameAlloc()
239+ if frame == nil {
240+ return errors.New("failed to allocate decode frame")
241+ }
242+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations.
243+ defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
244+
245+ encPkt := ffmpeg.PacketAlloc()
246+ if encPkt == nil {
247+ return errors.New("failed to allocate encode packet")
248+ }
249+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations.
250+ defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
251+
252+ for {
253+ // Check context cancellation.
254+ select {
255+ case <-ctx.Done():
256+ return ctx.Err()
257+ default:
258+ }
259+
260+ ret := ffmpeg.ReadFrame(inFmtCtx, pkt)
261+ if ret < 0 {
262+ // EOF or error — break to flush.
263+ break
264+ }
265+
266+ streamIdx := int(pktStreamIndex(pkt))
267+
268+ switch {
269+ case streamIdx == videoIdx:
270+ if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, outVideoIdx); err != nil {
271+ ffmpeg.PacketUnref(pkt)
272+ return fmt.Errorf("video transcode: %w", err)
273+ }
274+
275+ case audioTx != nil && streamIdx == audioIdx:
276+ if err := audioTx.processPacket(pkt, outFmtCtx); err != nil {
277+ // Audio errors are non-fatal: log warning, disable audio,
278+ // and continue with video-only output. This matches the
279+ // non-fatal handling of audio init failure above.
280+ p.logger.Warn().
281+ Str("session_id", p.sessionID).
282+ Err(err).
283+ Msg("audio processing failed, continuing with video only")
284+ audioTx.close()
285+ audioTx = nil
286+ audioIdx = -1
287+ }
288+ }
289+
290+ ffmpeg.PacketUnref(pkt)
291+ }
292+
293+ // --- Step 14: Flush decoders and encoders ---
294+ // Flush video decoder.
295+ ffmpeg.CodecSendPacket(vidDecCtx, nil)
296+ for {
297+ ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame)
298+ if ret < 0 {
299+ break
300+ }
301+ if err := p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, outVideoIdx); err != nil {
302+ ffmpeg.FrameUnref(frame)
303+ return fmt.Errorf("video flush: %w", err)
304+ }
305+ ffmpeg.FrameUnref(frame)
306+ }
307+
308+ // Flush video encoder.
309+ ffmpeg.CodecSendFrame(vidEncCtx, nil)
310+ for {
311+ ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt)
312+ if ret < 0 {
313+ break
314+ }
315+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
316+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
317+ outVidStreamWrap := ffmpeg.WrapStream(outVidStream)
318+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase())
319+ ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt)
320+ ffmpeg.PacketUnref(encPkt)
321+ }
322+
323+ // Flush audio.
324+ if audioTx != nil {
325+ audioTx.flush(outFmtCtx, p.logger)
326+ }
327+
328+ // --- Step 15: Write trailer ---
329+ if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 {
330+ return fmt.Errorf("write trailer: %d", ret)
331+ }
332+
333+ return nil
334+}
335+
336+func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioIdx int, err error) {
337+ videoIdx = -1
338+ audioIdx = -1
339+ bestVideoPixels := int64(-1)
340+
341+ streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx))
342+ for i := 0; i < streamCount; i++ {
343+ stream := ffmpeg.FmtCtxStream(inFmtCtx, i)
344+ if stream == nil {
345+ continue
346+ }
347+ streamWrap := ffmpeg.WrapStream(stream)
348+ codecPar := streamWrap.Codecpar()
349+ if codecPar == nil {
350+ continue
351+ }
352+
353+ switch ffmpeg.CodecParCodecType(codecPar) {
354+ case ffmpeg.AVMEDIA_TYPE_VIDEO:
355+ width := int64(ffmpeg.CodecParWidth(codecPar))
356+ height := int64(ffmpeg.CodecParHeight(codecPar))
357+ pixels := width * height
358+ if videoIdx == -1 || pixels > bestVideoPixels {
359+ videoIdx = i
360+ bestVideoPixels = pixels
361+ }
362+ case ffmpeg.AVMEDIA_TYPE_AUDIO:
363+ if audioIdx == -1 {
364+ audioIdx = i
365+ }
366+ }
367+ }
368+
369+ if videoIdx < 0 {
370+ return -1, audioIdx, errors.New("no video stream found in source")
371+ }
372+
373+ videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar()
374+ log := p.logger.With().
375+ Str("session_id", p.sessionID).
376+ Str("source_url", p.sourceURL).
377+ Int("video_stream_index", videoIdx).
378+ Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)).
379+ Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)).
380+ Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)).
381+ Logger()
382+ if audioIdx >= 0 {
383+ audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar()
384+ log.Info().
385+ Int("audio_stream_index", audioIdx).
386+ Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)).
387+ Msg("transcode pipeline selected input streams")
388+ } else {
389+ log.Info().Msg("transcode pipeline selected input streams")
390+ }
391+
392+ return videoIdx, audioIdx, nil
393+}
394+
395+func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {
396+ if IsStreamingManifestURL(p.sourceURL) {
397+ return p.openManifestInputContext()
398+ }
399+ return p.openCustomIOInputContext(ctx)
400+}
401+
402+func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) {
403+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, http.NoBody)
404+ if err != nil {
405+ return nil, nil, fmt.Errorf("create request: %w", err)
406+ }
407+ for k, v := range p.headers {
408+ req.Header.Set(k, v)
409+ }
410+
411+ resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading.
412+ if err != nil {
413+ return nil, nil, fmt.Errorf("fetch source: %w", err)
414+ }
415+
416+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
417+ _ = resp.Body.Close()
418+ return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
419+ }
420+
421+ body := resp.Body
422+ readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
423+ if bufSize <= 0 {
424+ return -1
425+ }
426+ //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here.
427+ goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
428+ // Retry up to 3 times for the edge case where body.Read returns
429+ // n=0, err=nil (allowed by the io.Reader contract but unusual).
430+ for tries := 0; tries < 3; tries++ {
431+ n, readErr := body.Read(goBuf)
432+ if n > 0 {
433+ return int32(n)
434+ }
435+ if readErr != nil {
436+ // Return AVERROR_EOF for EOF, generic error otherwise.
437+ return averrorEOF
438+ }
439+ // n == 0 && err == nil: retry
440+ }
441+ return averrorEOF
442+ }
443+ p.readCb = purego.NewCallback(readFn)
444+
445+ readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0)
446+ if readAVIO == nil {
447+ _ = resp.Body.Close()
448+ return nil, nil, errors.New("failed to allocate read AVIO context")
449+ }
450+
451+ inFmtCtx := ffmpeg.FormatAllocContext()
452+ if inFmtCtx == nil {
453+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
454+ ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
455+ _ = resp.Body.Close()
456+ return nil, nil, errors.New("failed to allocate input format context")
457+ }
458+
459+ ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO)
460+ ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
461+
462+ //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts.
463+ if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 {
464+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
465+ ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
466+ _ = resp.Body.Close()
467+ ffmpeg.FormatFreeContext(inFmtCtx)
468+ return nil, nil, fmt.Errorf("open input: %d", ret)
469+ }
470+
471+ cleanup := func() {
472+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
473+ ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
474+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
475+ ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
476+ _ = resp.Body.Close()
477+ }
478+ return inFmtCtx, cleanup, nil
479+}
480+
481+func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
482+ inFmtCtx := ffmpeg.FormatAllocContext()
483+ if inFmtCtx == nil {
484+ return nil, nil, errors.New("failed to allocate input format context")
485+ }
486+
487+ var opts unsafe.Pointer
488+ defer freeInputOptions(&opts)
489+
490+ if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil {
491+ ffmpeg.FormatFreeContext(inFmtCtx)
492+ return nil, nil, err
493+ }
494+ if userAgent := p.headers["User-Agent"]; userAgent != "" {
495+ if err := setInputOption(&opts, "user_agent", userAgent); err != nil {
496+ ffmpeg.FormatFreeContext(inFmtCtx)
497+ return nil, nil, err
498+ }
499+ }
500+ if referer := p.headers["Referer"]; referer != "" {
501+ if err := setInputOption(&opts, "referer", referer); err != nil {
502+ ffmpeg.FormatFreeContext(inFmtCtx)
503+ return nil, nil, err
504+ }
505+ }
506+
507+ headerLines := formatHTTPHeaderOptions(p.headers)
508+ if headerLines != "" {
509+ if err := setInputOption(&opts, "headers", headerLines); err != nil {
510+ ffmpeg.FormatFreeContext(inFmtCtx)
511+ return nil, nil, err
512+ }
513+ }
514+
515+ //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
516+ if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
517+ ffmpeg.FormatFreeContext(inFmtCtx)
518+ return nil, nil, fmt.Errorf("open manifest input: %d", ret)
519+ }
520+
521+ return inFmtCtx, func() {
522+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
523+ ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
524+ }, nil
525+}
526+
527+func freeInputOptions(opts *unsafe.Pointer) {
528+ if opts == nil || *opts == nil {
529+ return
530+ }
531+ // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
532+ // itself corrupts memory and can crash inside libavutil during cleanup.
533+ //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
534+ ffmpeg.DictFree(unsafe.Pointer(opts))
535+ *opts = nil
536+}
537+
538+func setInputOption(opts *unsafe.Pointer, key, value string) error {
539+ if value == "" {
540+ return nil
541+ }
542+ //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
543+ if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
544+ return fmt.Errorf("set input option %q: %d", key, ret)
545+ }
546+ return nil
547+}
548+
549+func formatHTTPHeaderOptions(headers map[string]string) string {
550+ if len(headers) == 0 {
551+ return ""
552+ }
553+
554+ keys := make([]string, 0, len(headers))
555+ for key, value := range headers {
556+ if strings.TrimSpace(value) == "" {
557+ continue
558+ }
559+ if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") {
560+ continue
561+ }
562+ keys = append(keys, key)
563+ }
564+ if len(keys) == 0 {
565+ return ""
566+ }
567+
568+ sort.Strings(keys)
569+
570+ var builder strings.Builder
571+ for _, key := range keys {
572+ builder.WriteString(key)
573+ builder.WriteString(": ")
574+ builder.WriteString(headers[key])
575+ builder.WriteString("\r\n")
576+ }
577+ return builder.String()
578+}
579+
580+// openVideoDecoder creates and opens a decoder context for the video stream.
581+// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
582+// falling back to the generic software decoder.
583+//
584+//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
585+func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
586+ codecID := ffmpeg.CodecParCodecID(codecPar)
587+
588+ // Try HW decoder first.
589+ var dec unsafe.Pointer
590+ for _, name := range p.hwCaps.Decoders {
591+ dec = ffmpeg.CodecFindDecoderByName(name)
592+ if dec != nil {
593+ break
594+ }
595+ }
596+
597+ // Fallback to software decoder.
598+ if dec == nil {
599+ dec = ffmpeg.CodecFindDecoder(codecID)
600+ }
601+ if dec == nil {
602+ return nil, fmt.Errorf("no decoder found for codec ID %d", codecID)
603+ }
604+
605+ decCtx := ffmpeg.CodecAllocContext3(dec)
606+ if decCtx == nil {
607+ return nil, errors.New("failed to allocate video decoder context")
608+ }
609+
610+ if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
611+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
612+ return nil, fmt.Errorf("copy video codec params: %d", ret)
613+ }
614+
615+ if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
616+ ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
617+ return nil, fmt.Errorf("open video decoder: %d", ret)
618+ }
619+
620+ return decCtx, nil
621+}
622+
623+// openVideoEncoder creates a hardware device context and encoder for
624+// the video stream. It uses the first available encoder from hwCaps.Encoders.
625+func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx, encCtx unsafe.Pointer, err error) {
626+ if len(p.hwCaps.Encoders) == 0 {
627+ return nil, nil, errors.New("no GPU encoders available")
628+ }
629+
630+ // Create the HW device context.
631+ var deviceType int32
632+ switch p.hwCaps.API {
633+ case hwAPINameVAAPI:
634+ deviceType = int32(ffmpeg.HwdeviceTypeVaapi)
635+ case "nvenc":
636+ deviceType = int32(ffmpeg.HwdeviceTypeCuda)
637+ default:
638+ return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API)
639+ }
640+
641+ hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device)
642+ if hwErr != nil {
643+ return nil, nil, fmt.Errorf("create HW device: %w", hwErr)
644+ }
645+
646+ width := ffmpeg.CodecCtxWidth(decCtx)
647+ height := ffmpeg.CodecCtxHeight(decCtx)
648+
649+ var lastErr error
650+ for _, name := range p.hwCaps.Encoders {
651+ enc := ffmpeg.CodecFindEncoderByName(name)
652+ if enc == nil {
653+ continue
654+ }
655+
656+ encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc)
657+ if err == nil {
658+ p.logger.Info().
659+ Str("session_id", p.sessionID).
660+ Str("source_url", p.sourceURL).
661+ Str("encoder", name).
662+ Str("api", p.hwCaps.API).
663+ Int32("width", width).
664+ Int32("height", height).
665+ Msg("transcode pipeline selected video encoder")
666+ return hwDeviceCtx, encCtx, nil
667+ }
668+ lastErr = err
669+ p.logger.Warn().
670+ Str("session_id", p.sessionID).
671+ Str("source_url", p.sourceURL).
672+ Str("encoder", name).
673+ Err(err).
674+ Msg("transcode pipeline encoder candidate failed")
675+ }
676+
677+ ffmpeg.BufferUnref(&hwDeviceCtx)
678+ if lastErr != nil {
679+ return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr)
680+ }
681+ return nil, nil, errors.New("no usable encoder codec found")
682+}
683+
684+func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) {
685+ encCtx := ffmpeg.CodecAllocContext3(enc)
686+ if encCtx == nil {
687+ return nil, errors.New("failed to allocate video encoder context")
688+ }
689+
690+ width := ffmpeg.CodecCtxWidth(decCtx)
691+ height := ffmpeg.CodecCtxHeight(decCtx)
692+ ffmpeg.CodecCtxSetWidth(encCtx, width)
693+ ffmpeg.CodecCtxSetHeight(encCtx, height)
694+
695+ framerate := ffmpeg.CodecCtxFramerate(decCtx)
696+ if framerate.Num == 0 {
697+ framerate = ffmpeg.AVRational{Num: 30, Den: 1}
698+ }
699+ ffmpeg.CodecCtxSetFramerate(encCtx, framerate)
700+ ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num})
701+
702+ if p.hwCaps.API == hwAPINameVAAPI {
703+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi))
704+ } else {
705+ ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12))
706+ }
707+
708+ gopSize := framerate.Num / framerate.Den * 2
709+ if gopSize <= 0 {
710+ gopSize = 60
711+ }
712+ codecCtxSetGopSize(encCtx, gopSize)
713+ p.applyQualityPreset(encCtx, encoderName, width, height)
714+
715+ hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
716+ if hwDeviceRef == nil {
717+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
718+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
719+ return nil, errors.New("failed to ref HW device context for encoder")
720+ }
721+ ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
722+
723+ if p.hwCaps.API == hwAPINameVAAPI {
724+ framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
725+ if framesRef == nil {
726+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
727+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
728+ return nil, errors.New("failed to allocate HW frames context")
729+ }
730+
731+ hwFramesData := ffmpeg.BufferRefData(framesRef)
732+ if hwFramesData == nil {
733+ ffmpeg.BufferUnref(&framesRef)
734+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
735+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
736+ return nil, errors.New("failed to access HW frames context payload")
737+ }
738+
739+ ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize)
740+ ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi))
741+ ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12))
742+ ffmpeg.HWFramesCtxSetWidth(hwFramesData, width)
743+ ffmpeg.HWFramesCtxSetHeight(hwFramesData, height)
744+
745+ if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
746+ ffmpeg.BufferUnref(&framesRef)
747+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
748+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
749+ return nil, fmt.Errorf("init HW frames context: %d", ret)
750+ }
751+
752+ ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef)
753+ }
754+
755+ if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
756+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
757+ ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
758+ return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
759+ }
760+
761+ return encCtx, nil
762+}
763+
764+// applyQualityPreset sets bitrate and encoder options based on the
765+// quality string ("low", "medium", "high").
766+func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) {
767+ pixels := int64(width) * int64(height)
768+
769+ // Base bitrate scaled to resolution (targeting 1080p baseline).
770+ // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps.
771+ var bitsPerPixel float64
772+ switch p.quality {
773+ case "low":
774+ bitsPerPixel = 0.07
775+ case "high":
776+ bitsPerPixel = 0.30
777+ default: // "medium"
778+ bitsPerPixel = 0.15
779+ }
780+
781+ bitrate := int64(float64(pixels) * bitsPerPixel)
782+ if bitrate < minVideoBitrate {
783+ bitrate = minVideoBitrate // floor at 500 kbps
784+ }
785+ codecCtxSetBitRate(encCtx, bitrate)
786+
787+ // Set encoder-specific options via av_opt_set.
788+ if strings.HasSuffix(encoderName, "_vaapi") {
789+ // VAAPI encoders use rc_mode for rate control.
790+ ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0)
791+ } else if strings.HasSuffix(encoderName, "_nvenc") {
792+ // NVENC uses preset and rc.
793+ switch p.quality {
794+ case "low":
795+ ffmpeg.AVOptSet(encCtx, "preset", "p4", 0)
796+ case "high":
797+ ffmpeg.AVOptSet(encCtx, "preset", "p7", 0)
798+ default:
799+ ffmpeg.AVOptSet(encCtx, "preset", "p5", 0)
800+ }
801+ ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0)
802+ }
803+}
804+
805+// transcodeVideoPacket decodes a video packet, encodes the resulting
806+// frames, and writes them to the output.
807+func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error {
808+ ret := ffmpeg.CodecSendPacket(decCtx, pkt)
809+ if ret < 0 {
810+ return fmt.Errorf("video decode send packet: %d", ret)
811+ }
812+
813+ for {
814+ ret = ffmpeg.CodecReceiveFrame(decCtx, frame)
815+ if ret < 0 {
816+ // EAGAIN or EOF.
817+ break
818+ }
819+
820+ if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, outVideoIdx); err != nil {
821+ ffmpeg.FrameUnref(frame)
822+ return err
823+ }
824+ ffmpeg.FrameUnref(frame)
825+ }
826+
827+ return nil
828+}
829+
830+// encodeAndWriteVideoFrame sends a decoded frame to the encoder and
831+// writes any resulting packets to the output.
832+func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error {
833+ encFrame := frame
834+ var cleanup func()
835+ if p.hwCaps.API == hwAPINameVAAPI {
836+ var err error
837+ encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx)
838+ if err != nil {
839+ return err
840+ }
841+ defer cleanup()
842+ }
843+
844+ ret := ffmpeg.CodecSendFrame(encCtx, encFrame)
845+ if ret < 0 {
846+ return fmt.Errorf("video encode send frame: %d", ret)
847+ }
848+
849+ for {
850+ ret = ffmpeg.CodecReceivePacket(encCtx, encPkt)
851+ if ret < 0 {
852+ break
853+ }
854+
855+ pktSetStreamIndex(encPkt, int32(outVideoIdx))
856+
857+ outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx)
858+ outVidStreamWrap := ffmpeg.WrapStream(outVidStream)
859+ ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase())
860+
861+ if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 {
862+ ffmpeg.PacketUnref(encPkt)
863+ return fmt.Errorf("video write frame: %d", wret)
864+ }
865+ ffmpeg.PacketUnref(encPkt)
866+ }
867+
868+ return nil
869+}
870+
871+func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) {
872+ if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) {
873+ return frame, func() {}, nil
874+ }
875+
876+ hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx)
877+ if hwFramesCtx == nil {
878+ return nil, nil, errors.New("video encoder missing hw_frames_ctx")
879+ }
880+
881+ hwFrame := ffmpeg.FrameAlloc()
882+ if hwFrame == nil {
883+ return nil, nil, errors.New("failed to allocate VAAPI frame")
884+ }
885+
886+ cleanup := func() {
887+ if hwFrame != nil {
888+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations.
889+ ffmpeg.FrameFree(unsafe.Pointer(&hwFrame))
890+ }
891+ }
892+
893+ frameSetFormat(hwFrame, int32(ffmpeg.PixFmtVaapi))
894+ frameSetWidth(hwFrame, frameWidth(frame))
895+ frameSetHeight(hwFrame, frameHeight(frame))
896+ frameSetPts(hwFrame, framePts(frame))
897+
898+ hwFramesRef := ffmpeg.BufferRef(hwFramesCtx)
899+ if hwFramesRef == nil {
900+ cleanup()
901+ return nil, nil, errors.New("failed to ref encoder hw frames context")
902+ }
903+ frameSetHWFramesCtx(hwFrame, hwFramesRef)
904+
905+ if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 {
906+ cleanup()
907+ return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret)
908+ }
909+ if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 {
910+ cleanup()
911+ return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret)
912+ }
913+
914+ return hwFrame, cleanup, nil
915+}
916+
917+// averrorEOF is the AVERROR_EOF constant.
918+const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24)
A internal/infrastructure/transcoder/pool.go +111 -0
@@ -0,0 +1,111 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: session implements port.TranscodeSession.
15+var _ port.TranscodeSession = (*session)(nil)
16+
17+// session wraps an io.PipeReader fed by a pipeline goroutine.
18+type session struct {
19+ id string
20+ pr *io.PipeReader
21+ cancel context.CancelFunc
22+ createdAt time.Time
23+ lastRead atomic.Int64 // unix timestamp of last Read() call
24+}
25+
26+// Read delegates to the underlying pipe reader and records the timestamp.
27+func (s *session) Read(buf []byte) (int, error) {
28+ n, err := s.pr.Read(buf)
29+ if n > 0 {
30+ s.lastRead.Store(time.Now().Unix())
31+ }
32+ return n, err
33+}
34+
35+// Close cancels the pipeline context and closes the pipe reader.
36+func (s *session) Close() error {
37+ s.cancel()
38+ return s.pr.Close()
39+}
40+
41+// ContentType returns the MIME type of the transcoded output.
42+func (*session) ContentType() string {
43+ return "video/webm"
44+}
45+
46+// errPoolAtCapacity is returned when the session pool has reached its
47+// maximum number of concurrent sessions.
48+var errPoolAtCapacity = errors.New("transcoder: session pool at capacity")
49+
50+// sessionPool tracks active transcode sessions with a concurrency limit.
51+type sessionPool struct {
52+ mu sync.RWMutex
53+ sessions map[string]*session
54+ maxConc int
55+}
56+
57+// newSessionPool creates a pool with the given concurrency limit.
58+func newSessionPool(maxConcurrent int) *sessionPool {
59+ return &sessionPool{
60+ sessions: make(map[string]*session),
61+ maxConc: maxConcurrent,
62+ }
63+}
64+
65+// add registers a session in the pool. Returns errPoolAtCapacity if the
66+// pool is already at its maximum concurrency.
67+func (p *sessionPool) add(s *session) error {
68+ p.mu.Lock()
69+ defer p.mu.Unlock()
70+ if len(p.sessions) >= p.maxConc {
71+ return errPoolAtCapacity
72+ }
73+ p.sessions[s.id] = s
74+ return nil
75+}
76+
77+// remove unregisters a session from the pool.
78+func (p *sessionPool) remove(id string) {
79+ p.mu.Lock()
80+ defer p.mu.Unlock()
81+ delete(p.sessions, id)
82+}
83+
84+// count returns the number of active sessions.
85+func (p *sessionPool) count() int {
86+ p.mu.RLock()
87+ defer p.mu.RUnlock()
88+ return len(p.sessions)
89+}
90+
91+// closeAll cancels and closes every active session. It collects sessions
92+// under the lock, clears the map, then releases the lock before calling
93+// Close on each session to avoid holding the lock during potentially
94+// blocking I/O.
95+func (p *sessionPool) closeAll() {
96+ p.mu.Lock()
97+ snapshot := make([]*session, 0, len(p.sessions))
98+ for _, s := range p.sessions {
99+ snapshot = append(snapshot, s)
100+ }
101+ // Clear the map while holding the lock.
102+ for id := range p.sessions {
103+ delete(p.sessions, id)
104+ }
105+ p.mu.Unlock()
106+
107+ // Close sessions outside the lock.
108+ for _, s := range snapshot {
109+ _ = s.Close()
110+ }
111+}
A internal/infrastructure/transcoder/transcoder.go +146 -0
@@ -0,0 +1,146 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/google/uuid"
11+ "github.com/rs/zerolog"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+)
16+
17+// Compile-time check: Transcoder implements port.MediaTranscoder.
18+var _ port.MediaTranscoder = (*Transcoder)(nil)
19+
20+// Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg
21+// pipelines. It probes hardware capabilities at creation time and manages
22+// a bounded pool of concurrent transcode sessions.
23+type Transcoder struct {
24+ cfg config.TranscodingConfig
25+ hwCaps port.HWCapabilities
26+ pool *sessionPool
27+ logger zerolog.Logger
28+}
29+
30+// New creates a Transcoder. It probes the GPU at creation time using the
31+// configured hardware acceleration preference. If no compatible GPU
32+// encoder is found, Available() will return false and Start() will fail.
33+func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder {
34+ maxConc := cfg.MaxConcurrent
35+ if maxConc <= 0 {
36+ maxConc = 2
37+ }
38+
39+ // Work on a local copy to avoid mutating a potentially shared config.
40+ localCfg := cfg
41+
42+ if localCfg.Quality == "" {
43+ localCfg.Quality = "medium"
44+ }
45+
46+ hwaccel := localCfg.HWAccel
47+ if hwaccel == "" {
48+ hwaccel = "auto"
49+ }
50+
51+ l := logger.With().Str("component", "transcoder").Logger()
52+
53+ hwCaps := ProbeGPU(hwaccel, &l)
54+ if len(hwCaps.Encoders) > 0 {
55+ l.Info().
56+ Str("api", hwCaps.API).
57+ Strs("encoders", hwCaps.Encoders).
58+ Strs("decoders", hwCaps.Decoders).
59+ Int("max_concurrent", maxConc).
60+ Msg("GPU transcoding available")
61+ } else {
62+ l.Warn().Msg("no GPU encoder found, transcoding will be unavailable")
63+ }
64+
65+ return &Transcoder{
66+ cfg: localCfg,
67+ hwCaps: hwCaps,
68+ pool: newSessionPool(maxConc),
69+ logger: l,
70+ }
71+}
72+
73+// Available returns true if a compatible GPU encoder was found during
74+// hardware probing.
75+func (t *Transcoder) Available() bool {
76+ return len(t.hwCaps.Encoders) > 0
77+}
78+
79+// Capabilities returns the detected GPU hardware info.
80+func (t *Transcoder) Capabilities() port.HWCapabilities {
81+ return t.hwCaps
82+}
83+
84+// Start begins a transcode session. It spawns a goroutine running the
85+// FFmpeg pipeline and returns a TranscodeSession whose Read() streams
86+// the transcoded WebM output. The session is automatically removed from
87+// the pool when the pipeline completes or the context is canceled.
88+func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) {
89+ if !t.Available() {
90+ return nil, errors.New("transcoder: no GPU encoder available")
91+ }
92+
93+ // Create a cancellable context for this session.
94+ sessionCtx, cancel := context.WithCancel(ctx)
95+
96+ pr, pw := io.Pipe()
97+
98+ id := uuid.NewString()
99+ s := &session{
100+ id: id,
101+ pr: pr,
102+ cancel: cancel,
103+ createdAt: time.Now(),
104+ }
105+ s.lastRead.Store(time.Now().Unix())
106+
107+ // Check capacity and register before spawning the goroutine.
108+ if err := t.pool.add(s); err != nil {
109+ cancel()
110+ _ = pr.Close()
111+ _ = pw.Close()
112+ return nil, fmt.Errorf("transcoder: %w", err)
113+ }
114+
115+ p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger)
116+
117+ t.logger.Info().
118+ Str("session_id", id).
119+ Str("source", sourceURL).
120+ Int("active_sessions", t.pool.count()).
121+ Msg("starting transcode session")
122+
123+ go func() {
124+ defer func() {
125+ t.pool.remove(id)
126+ t.logger.Info().Str("session_id", id).Msg("transcode session finished")
127+ }()
128+ defer func() {
129+ if r := recover(); r != nil {
130+ t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked")
131+ _ = pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r))
132+ }
133+ }()
134+ p.run(sessionCtx)
135+ }()
136+
137+ return s, nil
138+}
139+
140+// Close shuts down all active transcode sessions.
141+func (t *Transcoder) Close() {
142+ t.logger.Info().
143+ Int("active_sessions", t.pool.count()).
144+ Msg("closing all transcode sessions")
145+ t.pool.closeAll()
146+}
M internal/infrastructure/webkit/engine.go +3 -12
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
9 "github.com/bnema/dumber/internal/infrastructure/filtering"9 "github.com/bnema/dumber/internal/infrastructure/filtering"
10+ "github.com/bnema/dumber/internal/infrastructure/handlers"
10 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
11 )12 )
12 13
@@ -23,10 +24,6 @@ type Engine struct {
23 schemeHandler *DumbSchemeHandler24 schemeHandler *DumbSchemeHandler
24 schemePath string25 schemePath string
25 logger zerolog.Logger26 logger zerolog.Logger
26-
27- // Handler registrars - injected at construction to avoid import cycles.
28- handlerRegistrar func(ctx context.Context, router *MessageRouter, deps port.HandlerDependencies) error
29- accentHandlerRegistrar func(ctx context.Context, router *MessageRouter, handler port.AccentKeyHandler) error
30 }27 }
31 28
32 // Compile-time check that Engine implements port.Engine.29 // Compile-time check that Engine implements port.Engine.
@@ -95,10 +92,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
95 if e.messageRouter == nil {92 if e.messageRouter == nil {
96 return fmt.Errorf("message router not initialized")93 return fmt.Errorf("message router not initialized")
97 }94 }
98- if e.handlerRegistrar == nil {95+ return handlers.RegisterAll(ctx, e.messageRouter, deps)
99- return fmt.Errorf("handler registrar not configured")
100- }
101- return e.handlerRegistrar(ctx, e.messageRouter, deps)
102 }96 }
103 97
104 // RegisterAccentHandlers registers accent/dead-key input handlers.98 // RegisterAccentHandlers registers accent/dead-key input handlers.
@@ -106,10 +100,7 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
106 if e.messageRouter == nil {100 if e.messageRouter == nil {
107 return fmt.Errorf("message router not initialized")101 return fmt.Errorf("message router not initialized")
108 }102 }
109- if e.accentHandlerRegistrar == nil {103+ return handlers.RegisterAccentHandlers(ctx, e.messageRouter, handler)
110- return fmt.Errorf("accent handler registrar not configured")
111- }
112- return e.accentHandlerRegistrar(ctx, e.messageRouter, handler)
113 }104 }
114 105
115 // ConfigureDownloads sets up download handling.106 // ConfigureDownloads sets up download handling.
M internal/infrastructure/webkit/engine_init.go +11 -15
@@ -26,8 +26,6 @@ func NewEngine(
26 themeManager *theme.Manager,26 themeManager *theme.Manager,
27 colorResolver port.ColorSchemeResolver,27 colorResolver port.ColorSchemeResolver,
28 logger zerolog.Logger,28 logger zerolog.Logger,
29- handlerRegistrar func(ctx context.Context, router *MessageRouter, deps port.HandlerDependencies) error,
30- accentHandlerRegistrar func(ctx context.Context, router *MessageRouter, handler port.AccentKeyHandler) error,
31 ) (*Engine, error) {29 ) (*Engine, error) {
32 // --- Hardware survey and performance profile resolution ---30 // --- Hardware survey and performance profile resolution ---
33 perfSettings := engineSurveyHardwareAndResolveProfile(ctx, cfg, logger)31 perfSettings := engineSurveyHardwareAndResolveProfile(ctx, cfg, logger)
@@ -106,19 +104,17 @@ func NewEngine(
106 104
107 // --- Assemble Engine ---105 // --- Assemble Engine ---
108 engine := &Engine{106 engine := &Engine{
109- ctx: ctx,107+ ctx: ctx,
110- wkCtx: wkCtx,108+ wkCtx: wkCtx,
111- settings: settings,109+ settings: settings,
112- injector: injector,110+ injector: injector,
113- messageRouter: messageRouter,111+ messageRouter: messageRouter,
114- pool: pool,112+ pool: pool,
115- factory: factory,113+ factory: factory,
116- filterManager: filterManager,114+ filterManager: filterManager,
117- schemeHandler: schemeHandler,115+ schemeHandler: schemeHandler,
118- schemePath: "dumb://",116+ schemePath: "dumb://",
119- logger: logger,117+ logger: logger,
120- handlerRegistrar: handlerRegistrar,
121- accentHandlerRegistrar: accentHandlerRegistrar,
122 }118 }
123 119
124 return engine, nil120 return engine, nil
D internal/infrastructure/webkit/handlers/register_test.go +0 -40
@@ -1,40 +0,0 @@
1-package handlers
2-
3-import (
4- "context"
5- "testing"
6-
7- "github.com/bnema/dumber/internal/infrastructure/webkit"
8- "github.com/stretchr/testify/assert"
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestRegisterAccentHandlers_NilRouter(t *testing.T) {
13- ctx := context.Background()
14- handler := &stubAccentHandler{}
15- err := RegisterAccentHandlers(ctx, nil, handler)
16- require.Error(t, err)
17- assert.Contains(t, err.Error(), "router")
18-}
19-
20-func TestRegisterAccentHandlers_NilHandler(t *testing.T) {
21- ctx := context.Background()
22- router := webkit.NewMessageRouter(ctx)
23- err := RegisterAccentHandlers(ctx, router, nil)
24- require.Error(t, err)
25- assert.Contains(t, err.Error(), "handler")
26-}
27-
28-func TestRegisterAccentHandlers_Success(t *testing.T) {
29- ctx := context.Background()
30- router := webkit.NewMessageRouter(ctx)
31- handler := &stubAccentHandler{}
32- err := RegisterAccentHandlers(ctx, router, handler)
33- require.NoError(t, err)
34-}
35-
36-// stubAccentHandler satisfies AccentKeyHandler for tests.
37-type stubAccentHandler struct{}
38-
39-func (*stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { return false }
40-func (*stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {}
M internal/infrastructure/webkit/injector.go +3 -90
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webutil"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
13 )14 )
@@ -28,91 +29,6 @@ const themeCSSScript = `(function() {
28 (document.head || document.documentElement).appendChild(style);29 (document.head || document.documentElement).appendChild(style);
29 })();`30 })();`
30 31
31-// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
32-// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
33-// for JS-based dark mode detection in WebUI components.
34-//
35-// NOTE: This script is NOT injected on external pages. External pages receive
36-// dark mode preference via libadwaita's StyleManager, which WebKit respects
37-// for the native prefers-color-scheme media query.
38-//
39-// The matchMedia patch handles various query formats:
40-// - (prefers-color-scheme: dark)
41-// - (prefers-color-scheme:dark) -- no space
42-// - screen and (prefers-color-scheme: dark)
43-const internalDarkModeScriptTemplate = `(function() {
44- var prefersDark = %t;
45- window.__dumber_gtk_prefers_dark = prefersDark;
46- var originalMatchMedia = window.matchMedia.bind(window);
47-
48- // Apply dark/light class to document element for Tailwind CSS theming
49- if (prefersDark) {
50- document.documentElement.classList.add('dark');
51- document.documentElement.classList.remove('light');
52- } else {
53- document.documentElement.classList.add('light');
54- document.documentElement.classList.remove('dark');
55- }
56-
57- // Helper: Check if query is a prefers-color-scheme query
58- function isColorSchemeQuery(query, scheme) {
59- if (typeof query !== 'string') return false;
60- var normalized = query.replace(/\s+/g, '').toLowerCase();
61- return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
62- }
63-
64- // Create a fake MediaQueryList that implements the full interface
65- function createFakeMediaQueryList(query, matches) {
66- var listeners = [];
67- var onchangeHandler = null;
68-
69- return {
70- matches: matches,
71- media: query,
72- get onchange() { return onchangeHandler; },
73- set onchange(fn) { onchangeHandler = fn; },
74- addListener: function(cb) {
75- if (typeof cb === 'function') listeners.push(cb);
76- },
77- removeListener: function(cb) {
78- var idx = listeners.indexOf(cb);
79- if (idx !== -1) listeners.splice(idx, 1);
80- },
81- addEventListener: function(type, cb) {
82- if (type === 'change' && typeof cb === 'function') {
83- listeners.push(cb);
84- }
85- },
86- removeEventListener: function(type, cb) {
87- if (type === 'change') {
88- var idx = listeners.indexOf(cb);
89- if (idx !== -1) listeners.splice(idx, 1);
90- }
91- },
92- dispatchEvent: function(event) {
93- for (var i = 0; i < listeners.length; i++) {
94- try { listeners[i](event); } catch (e) {}
95- }
96- if (onchangeHandler) {
97- try { onchangeHandler(event); } catch (e) {}
98- }
99- return true;
100- }
101- };
102- }
103-
104- // Patch matchMedia for prefers-color-scheme queries
105- window.matchMedia = function(query) {
106- if (isColorSchemeQuery(query, 'dark')) {
107- return createFakeMediaQueryList(query, prefersDark);
108- }
109- if (isColorSchemeQuery(query, 'light')) {
110- return createFakeMediaQueryList(query, !prefersDark);
111- }
112- return originalMatchMedia(query);
113- };
114-})();`
115-
116 // internalPageAllowList restricts script injection to internal dumb:// pages only.32 // internalPageAllowList restricts script injection to internal dumb:// pages only.
117 var internalPageAllowList = []string{"dumb://*"}33 var internalPageAllowList = []string{"dumb://*"}
118 34
@@ -333,7 +249,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
333 249
334 // 3. Inject dark mode handler for internal pages only250 // 3. Inject dark mode handler for internal pages only
335 // This sets .dark/.light class on <html> and patches matchMedia for WebUI251 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
336- internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)252+ internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
337 addScript(253 addScript(
338 webkit.NewUserScript(254 webkit.NewUserScript(
339 internalDarkModeScript,255 internalDarkModeScript,
@@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
347 263
348 // 4. Inject theme CSS for internal pages (dumb://* only)264 // 4. Inject theme CSS for internal pages (dumb://* only)
349 if ci.themeCSSVars != "" {265 if ci.themeCSSVars != "" {
350- // Escape for JS string literal266+ escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
351- escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
352- escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
353- escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
354 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)267 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
355 addScript(268 addScript(
356 webkit.NewUserScript(269 webkit.NewUserScript(
M internal/infrastructure/webkit/messaging.go +12 -18
@@ -7,31 +7,22 @@ import (
7 "fmt"7 "fmt"
8 "sync"8 "sync"
9 9
10+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/puregotk-webkit/javascriptcore"12 "github.com/bnema/puregotk-webkit/javascriptcore"
12 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
13 )14 )
14 15
15-// Message represents a JS -> Go message envelope sent via postMessage.16+// Message is the JS -> Go message envelope sent via postMessage.
16-type Message struct {17+type Message = port.WebUIMessage
17- Type string `json:"type"`
18- Payload json.RawMessage `json:"payload"`
19- WebViewID uint64 `json:"webview_id,omitempty"`
20- WebViewIDAlt uint64 `json:"webviewId,omitempty"`
21-}
22 18
23-// MessageHandler handles a decoded message payload.19+// MessageHandler is an alias for port.WebUIMessageHandler.
24-type MessageHandler interface {20+// Kept for backward compatibility within the webkit package.
25- Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)21+type MessageHandler = port.WebUIMessageHandler
26-}
27 22
28-// MessageHandlerFunc adapts a function to the MessageHandler interface.23+// MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc.
29-type MessageHandlerFunc func(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)24+// Kept for backward compatibility within the webkit package.
30-25+type MessageHandlerFunc = port.WebUIMessageHandlerFunc
31-// Handle calls f(ctx, webviewID, payload).
32-func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) {
33- return f(ctx, webviewID, payload)
34-}
35 26
36 type handlerEntry struct {27 type handlerEntry struct {
37 handler MessageHandler28 handler MessageHandler
@@ -40,6 +31,9 @@ type handlerEntry struct {
40 world string31 world string
41 }32 }
42 33
34+// Compile-time check that MessageRouter implements port.WebUIHandlerRouter.
35+var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
36+
43 // MessageRouter dispatches script-message events to registered handlers.37 // MessageRouter dispatches script-message events to registered handlers.
44 type MessageRouter struct {38 type MessageRouter struct {
45 handlers map[string]handlerEntry39 handlers map[string]handlerEntry
M internal/infrastructure/webkit/scheme_handler.go +5 -282
@@ -5,9 +5,7 @@ import (
5 "embed"5 "embed"
6 "encoding/json"6 "encoding/json"
7 "fmt"7 "fmt"
8- "html"
9 "io/fs"8 "io/fs"
10- "mime"
11 "net/http"9 "net/http"
12 "net/url"10 "net/url"
13 "path/filepath"11 "path/filepath"
@@ -17,6 +15,7 @@ import (
17 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
19 "github.com/bnema/dumber/internal/infrastructure/env"17 "github.com/bnema/dumber/internal/infrastructure/env"
18+ "github.com/bnema/dumber/internal/infrastructure/webutil"
20 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
21 "github.com/bnema/puregotk-webkit/soup"20 "github.com/bnema/puregotk-webkit/soup"
22 "github.com/bnema/puregotk-webkit/webkit"21 "github.com/bnema/puregotk-webkit/webkit"
@@ -74,60 +73,6 @@ type DumbSchemeHandler struct {
74 ctx context.Context73 ctx context.Context
75 }74 }
76 75
77-type configAppearancePayload struct {
78- SansFont string `json:"sans_font"`
79- SerifFont string `json:"serif_font"`
80- MonospaceFont string `json:"monospace_font"`
81- DefaultFontSize int `json:"default_font_size"`
82- ColorScheme string `json:"color_scheme"`
83- LightPalette config.ColorPalette `json:"light_palette"`
84- DarkPalette config.ColorPalette `json:"dark_palette"`
85-}
86-
87-type configHardwarePayload struct {
88- CPUCores int `json:"cpu_cores"`
89- CPUThreads int `json:"cpu_threads"`
90- TotalRAMMB int `json:"total_ram_mb"`
91- GPUVendor string `json:"gpu_vendor"`
92- GPUName string `json:"gpu_name"`
93- VRAMMB int `json:"vram_mb"`
94-}
95-
96-type configPerformancePayload struct {
97- Profile string `json:"profile"`
98- Custom configCustomPerformance `json:"custom"`
99- Resolved configResolvedPerformance `json:"resolved"`
100- Hardware configHardwarePayload `json:"hardware"`
101-}
102-
103-// configCustomPerformance holds user-editable fields for custom profile.
104-type configCustomPerformance struct {
105- SkiaCPUThreads int `json:"skia_cpu_threads"`
106- SkiaGPUThreads int `json:"skia_gpu_threads"`
107- WebProcessMemoryMB int `json:"web_process_memory_mb"`
108- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
109- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
110-}
111-
112-// configResolvedPerformance shows the actual values that will be applied at startup.
113-type configResolvedPerformance struct {
114- SkiaCPUThreads int `json:"skia_cpu_threads"`
115- SkiaGPUThreads int `json:"skia_gpu_threads"`
116- WebProcessMemoryMB int `json:"web_process_memory_mb"`
117- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
118- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
119- ConservativeThreshold float64 `json:"conservative_threshold"`
120- StrictThreshold float64 `json:"strict_threshold"`
121-}
122-
123-type configPayload struct {
124- Appearance configAppearancePayload `json:"appearance"`
125- Performance configPerformancePayload `json:"performance"`
126- DefaultUIScale float64 `json:"default_ui_scale"`
127- DefaultSearchEngine string `json:"default_search_engine"`
128- SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
129-}
130-
131 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.76 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
132 func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {77 func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
133 log := logging.FromContext(ctx)78 log := logging.FromContext(ctx)
@@ -209,131 +154,11 @@ func crashOriginalURI(requestURI string) string {
209 }154 }
210 155
211 func sanitizeCrashPageOriginalURI(originalURI string) string {156 func sanitizeCrashPageOriginalURI(originalURI string) string {
212- originalURI = strings.TrimSpace(originalURI)157+ return webutil.SanitizeCrashPageOriginalURI(originalURI)
213- if originalURI == "" {
214- return ""
215- }
216- parsed, err := url.Parse(originalURI)
217- if err != nil {
218- return ""
219- }
220- switch strings.ToLower(parsed.Scheme) {
221- case "http", "https":
222- if parsed.Host == "" {
223- return ""
224- }
225- return parsed.String()
226- case "dumb":
227- if parsed.Host == "" && parsed.Opaque == "" {
228- return ""
229- }
230- return parsed.String()
231- default:
232- return ""
233- }
234 }158 }
235 159
236 func buildCrashPageHTML(originalURI string) string {160 func buildCrashPageHTML(originalURI string) string {
237- escapedURI := html.EscapeString(originalURI)161+ return webutil.BuildCrashPageHTML(originalURI)
238- return fmt.Sprintf(`<!DOCTYPE html>
239-<html lang="en">
240-<head>
241- <meta charset="utf-8">
242- <meta name="viewport" content="width=device-width, initial-scale=1">
243- <title>Renderer crashed</title>
244- <style>
245- :root {
246- color-scheme: dark;
247- font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
248- }
249- body {
250- margin: 0;
251- min-height: 100vh;
252- display: flex;
253- align-items: center;
254- justify-content: center;
255- background: radial-gradient(circle at top, #253447, #101622 55%%);
256- color: #f2f6fa;
257- padding: 24px;
258- }
259- .card {
260- width: min(640px, 100%%);
261- background: rgba(10, 16, 26, 0.86);
262- border: 1px solid rgba(144, 173, 205, 0.35);
263- border-radius: 16px;
264- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
265- padding: 28px;
266- }
267- h1 {
268- margin: 0 0 12px;
269- font-size: 1.8rem;
270- }
271- p {
272- margin: 0 0 16px;
273- line-height: 1.5;
274- color: #c8d6e6;
275- }
276- .url {
277- margin: 0 0 20px;
278- padding: 12px;
279- border-radius: 10px;
280- background: rgba(26, 38, 56, 0.85);
281- border: 1px solid rgba(139, 167, 194, 0.28);
282- font-family: "IBM Plex Mono", "Fira Code", monospace;
283- font-size: 0.92rem;
284- overflow-wrap: anywhere;
285- }
286- .actions {
287- display: flex;
288- gap: 12px;
289- flex-wrap: wrap;
290- }
291- button {
292- border: 0;
293- border-radius: 10px;
294- padding: 10px 16px;
295- cursor: pointer;
296- font-size: 0.95rem;
297- font-weight: 600;
298- }
299- .primary {
300- background: #4dd0e1;
301- color: #061018;
302- }
303- .secondary {
304- background: #233346;
305- color: #d6e5f5;
306- }
307- </style>
308-</head>
309-<body>
310- <div class="card">
311- <h1>Renderer process ended</h1>
312- <p>The current page was interrupted. You can reload it to continue browsing.</p>
313- <div class="url">%s</div>
314- <div class="actions">
315- <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
316- <button class="secondary" id="stay-btn">Stay on this page</button>
317- </div>
318- </div>
319- <script>
320- const reloadButton = document.getElementById('reload-btn');
321- const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
322- reloadButton.addEventListener('click', function() {
323- if (targetUrl) {
324- window.location.href = targetUrl;
325- return;
326- }
327- window.location.reload();
328- });
329- // "Stay on this page" keeps the crash page visible without navigating away.
330- document.getElementById('stay-btn').addEventListener('click', function() {
331- this.disabled = true;
332- this.textContent = 'Staying on page';
333- });
334- </script>
335-</body>
336-</html>`, escapedURI, escapedURI)
337 }162 }
338 163
339 func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {164 func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
@@ -346,44 +171,7 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo
346 hw = &hwInfo171 hw = &hwInfo
347 }172 }
348 173
349- // Resolve performance profile with hardware info174+ resp := config.BuildWebUIConfigPayload(cfg, hw)
350- perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
351- resolved := config.ResolvePerformanceProfile(&perfCfg, hw)
352-
353- resp := configPayload{
354- DefaultUIScale: cfg.DefaultUIScale,
355- DefaultSearchEngine: cfg.DefaultSearchEngine,
356- SearchShortcuts: cfg.SearchShortcuts,
357- Appearance: configAppearancePayload{
358- SansFont: cfg.Appearance.SansFont,
359- SerifFont: cfg.Appearance.SerifFont,
360- MonospaceFont: cfg.Appearance.MonospaceFont,
361- DefaultFontSize: cfg.Appearance.DefaultFontSize,
362- ColorScheme: cfg.Appearance.ColorScheme,
363- LightPalette: cfg.Appearance.LightPalette,
364- DarkPalette: cfg.Appearance.DarkPalette,
365- },
366- Performance: configPerformancePayload{
367- Profile: string(cfg.Engine.Profile),
368- Custom: configCustomPerformance{
369- SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
370- SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
371- WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
372- NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
373- WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
374- },
375- Resolved: configResolvedPerformance{
376- SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
377- SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
378- WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
379- NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
380- WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
381- ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
382- StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
383- },
384- Hardware: buildHardwarePayload(hw),
385- },
386- }
387 175
388 data, err := json.Marshal(resp)176 data, err := json.Marshal(resp)
389 if err != nil {177 if err != nil {
@@ -401,21 +189,6 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo
401 }189 }
402 }190 }
403 191
404-// buildHardwarePayload converts HardwareInfo to JSON payload.
405-func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload {
406- if hw == nil {
407- return configHardwarePayload{}
408- }
409- return configHardwarePayload{
410- CPUCores: hw.CPUCores,
411- CPUThreads: hw.CPUThreads,
412- TotalRAMMB: hw.TotalRAMMB(),
413- GPUVendor: string(hw.GPUVendor),
414- GPUName: hw.GPUName,
415- VRAMMB: hw.VRAMMB(),
416- }
417-}
418-
419 // RegisterPage registers a handler for a specific path.192 // RegisterPage registers a handler for a specific path.
420 func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {193 func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
421 h.mu.Lock()194 h.mu.Lock()
@@ -526,7 +299,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
526 return nil299 return nil
527 }300 }
528 301
529- contentType := h.getMimeType(relPath)302+ contentType := webutil.GetMimeType(relPath)
530 h.logger.Debug().303 h.logger.Debug().
531 Str("path", fullPath).304 Str("path", fullPath).
532 Str("content_type", contentType).305 Str("content_type", contentType).
@@ -574,56 +347,6 @@ func resolveAssetPath(u *url.URL) (string, bool) {
574 }347 }
575 }348 }
576 349
577-// getMimeType determines the MIME type for a given file path.
578-func (h *DumbSchemeHandler) getMimeType(filename string) string {
579- if h == nil {
580- return "application/octet-stream"
581- }
582- ext := strings.ToLower(filepath.Ext(filename))
583-
584- // Try standard mime type first
585- mt := mime.TypeByExtension(ext)
586- if mt != "" {
587- return mt
588- }
589-
590- // Fallbacks for common web assets
591- switch ext {
592- case ".js", ".mjs":
593- return "application/javascript"
594- case ".css":
595- return "text/css"
596- case ".svg":
597- return "image/svg+xml"
598- case ".ico":
599- return "image/x-icon"
600- case ".woff":
601- return "font/woff"
602- case ".woff2":
603- return "font/woff2"
604- case ".ttf":
605- return "font/ttf"
606- case ".otf":
607- return "font/otf"
608- case ".png":
609- return "image/png"
610- case ".jpg", ".jpeg":
611- return "image/jpeg"
612- case ".gif":
613- return "image/gif"
614- case ".webp":
615- return "image/webp"
616- case ".json":
617- return "application/json"
618- case ".xml":
619- return "application/xml"
620- case ".html", ".htm":
621- return "text/html; charset=utf-8"
622- default:
623- return "text/plain"
624- }
625-}
626-
627 // sendResponse sends the response back to WebKit.350 // sendResponse sends the response back to WebKit.
628 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {351 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
629 if response == nil {352 if response == nil {
A internal/infrastructure/webutil/crash_page.go +117 -0
@@ -0,0 +1,117 @@
1+package webutil
2+
3+import (
4+ "fmt"
5+ "html"
6+ "net/url"
7+ "strings"
8+)
9+
10+// SanitizeCrashPageOriginalURI validates and sanitizes a URI for display on a crash page.
11+// Only http, https, and dumb schemes are allowed. Non-allowlisted schemes such as
12+// javascript:, data:, and vbscript: are intentionally rejected to prevent XSS when
13+// the URI is rendered into crash page HTML.
14+func SanitizeCrashPageOriginalURI(originalURI string) string {
15+ originalURI = strings.TrimSpace(originalURI)
16+ if originalURI == "" {
17+ return ""
18+ }
19+ parsed, err := url.Parse(originalURI)
20+ if err != nil {
21+ return ""
22+ }
23+ switch strings.ToLower(parsed.Scheme) {
24+ case "http", "https":
25+ if parsed.Host == "" {
26+ return ""
27+ }
28+ return parsed.String()
29+ case "dumb":
30+ if parsed.Host == "" && parsed.Opaque == "" {
31+ return ""
32+ }
33+ return parsed.String()
34+ default: // Security: reject all non-allowlisted schemes (javascript:, data:, vbscript:, etc.)
35+ return ""
36+ }
37+}
38+
39+// BuildCrashPageHTML returns a self-contained HTML page for renderer crash recovery.
40+func BuildCrashPageHTML(originalURI string) string {
41+ escapedURI := html.EscapeString(originalURI)
42+ return fmt.Sprintf(`<!DOCTYPE html>
43+<html lang="en">
44+<head>
45+ <meta charset="utf-8">
46+ <meta name="viewport" content="width=device-width, initial-scale=1">
47+ <title>Renderer crashed</title>
48+ <style>
49+ :root { color-scheme: dark; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; }
50+ body {
51+ margin: 0;
52+ min-height: 100vh;
53+ display: flex;
54+ align-items: center;
55+ justify-content: center;
56+ background: radial-gradient(circle at top, #253447, #101622 55%%);
57+ color: #f2f6fa;
58+ padding: 24px;
59+ }
60+ .card {
61+ width: min(640px, 100%%);
62+ background: rgba(10, 16, 26, 0.86);
63+ border: 1px solid rgba(144, 173, 205, 0.35);
64+ border-radius: 16px;
65+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
66+ padding: 28px;
67+ }
68+ .url {
69+ margin: 16px 0 20px;
70+ padding: 12px;
71+ border-radius: 10px;
72+ background: rgba(26, 38, 56, 0.85);
73+ border: 1px solid rgba(139, 167, 194, 0.28);
74+ font-family: "IBM Plex Mono", "Fira Code", monospace;
75+ overflow-wrap: anywhere;
76+ }
77+ .actions { display: flex; gap: 12px; flex-wrap: wrap; }
78+ button {
79+ border: 0;
80+ border-radius: 10px;
81+ padding: 10px 16px;
82+ cursor: pointer;
83+ font-size: 0.95rem;
84+ font-weight: 600;
85+ }
86+ .primary { background: #4dd0e1; color: #061018; }
87+ .secondary { background: #233346; color: #d6e5f5; }
88+ </style>
89+</head>
90+<body>
91+ <div class="card">
92+ <h1>Renderer process ended</h1>
93+ <p>The current page was interrupted. You can reload it to continue browsing.</p>
94+ <div class="url">%s</div>
95+ <div class="actions">
96+ <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
97+ <button class="secondary" id="stay-btn">Stay on this page</button>
98+ </div>
99+ </div>
100+ <script>
101+ const reloadButton = document.getElementById('reload-btn');
102+ const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
103+ reloadButton.addEventListener('click', function() {
104+ if (targetUrl) {
105+ window.location.href = targetUrl;
106+ return;
107+ }
108+ window.location.reload();
109+ });
110+ document.getElementById('stay-btn').addEventListener('click', function() {
111+ this.disabled = true;
112+ this.textContent = 'Staying on page';
113+ });
114+ </script>
115+</body>
116+</html>`, escapedURI, escapedURI)
117+}
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+}
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+}
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+}
M internal/logging/logger.go +50 -2
@@ -1,9 +1,11 @@
1 package logging1 package logging
2 2
3 import (3 import (
4+ "errors"
4 "io"5 "io"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
8+ "sync"
7 "time"9 "time"
8 10
9 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
@@ -105,8 +107,9 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
105 return zerolog.Logger{}, nil, err107 return zerolog.Logger{}, nil, err
106 }108 }
107 109
108- writers = append(writers, file)110+ sessionWriter := newSessionFileWriter(file)
109- cleanup = func() { _ = file.Close() }111+ writers = append(writers, sessionWriter)
112+ cleanup = func() { _ = sessionWriter.Close() }
110 }113 }
111 114
112 // Fallback to discard if no writers configured115 // Fallback to discard if no writers configured
@@ -123,6 +126,51 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
123 return ctx.Logger(), cleanup, nil126 return ctx.Logger(), cleanup, nil
124 }127 }
125 128
129+type sessionFileWriter struct {
130+ mu sync.Mutex
131+ file *os.File
132+ closed bool
133+}
134+
135+func newSessionFileWriter(file *os.File) *sessionFileWriter {
136+ return &sessionFileWriter{file: file}
137+}
138+
139+func (w *sessionFileWriter) Write(p []byte) (int, error) {
140+ w.mu.Lock()
141+ defer w.mu.Unlock()
142+
143+ if w.closed || w.file == nil {
144+ return len(p), nil
145+ }
146+
147+ n, err := w.file.Write(p)
148+ if errors.Is(err, os.ErrClosed) {
149+ w.closed = true
150+ w.file = nil
151+ return len(p), nil
152+ }
153+
154+ return n, err
155+}
156+
157+func (w *sessionFileWriter) Close() error {
158+ w.mu.Lock()
159+ defer w.mu.Unlock()
160+
161+ if w.closed || w.file == nil {
162+ return nil
163+ }
164+
165+ err := w.file.Close()
166+ w.closed = true
167+ w.file = nil
168+ if errors.Is(err, os.ErrClosed) {
169+ return nil
170+ }
171+ return err
172+}
173+
126 // NewFromEnv creates a logger based on environment variables174 // NewFromEnv creates a logger based on environment variables
127 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)175 // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
128 // DUMBER_LOG_FORMAT: json, console (default: console)176 // DUMBER_LOG_FORMAT: json, console (default: console)
A internal/logging/logger_test.go +74 -0
@@ -0,0 +1,74 @@
1+package logging
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
10+ t.Parallel()
11+
12+ dir := t.TempDir()
13+ path := filepath.Join(dir, "session.log")
14+
15+ file, err := os.Create(path)
16+ if err != nil {
17+ t.Fatalf("create temp log file: %v", err)
18+ }
19+
20+ writer := newSessionFileWriter(file)
21+
22+ n, err := writer.Write([]byte("first\n"))
23+ if err != nil {
24+ t.Fatalf("write before close: %v", err)
25+ }
26+ if n != len("first\n") {
27+ t.Fatalf("write before close returned %d, want %d", n, len("first\n"))
28+ }
29+
30+ if closeErr := writer.Close(); closeErr != nil {
31+ t.Fatalf("close writer: %v", closeErr)
32+ }
33+
34+ n, err = writer.Write([]byte("second\n"))
35+ if err != nil {
36+ t.Fatalf("write after close: %v", err)
37+ }
38+ if n != len("second\n") {
39+ t.Fatalf("write after close returned %d, want %d", n, len("second\n"))
40+ }
41+
42+ data, err := os.ReadFile(path)
43+ if err != nil {
44+ t.Fatalf("read log file: %v", err)
45+ }
46+ if got, want := string(data), "first\n"; got != want {
47+ t.Fatalf("log file contents = %q, want %q", got, want)
48+ }
49+}
50+
51+func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) {
52+ t.Parallel()
53+
54+ dir := t.TempDir()
55+ path := filepath.Join(dir, "session.log")
56+
57+ file, err := os.Create(path)
58+ if err != nil {
59+ t.Fatalf("create temp log file: %v", err)
60+ }
61+
62+ writer := newSessionFileWriter(file)
63+ if closeErr := file.Close(); closeErr != nil {
64+ t.Fatalf("close underlying file: %v", closeErr)
65+ }
66+
67+ n, err := writer.Write([]byte("late\n"))
68+ if err != nil {
69+ t.Fatalf("write after external close: %v", err)
70+ }
71+ if n != len("late\n") {
72+ t.Fatalf("write after external close returned %d, want %d", n, len("late\n"))
73+ }
74+}
M internal/ui/adapter/favicon.go +164 -50
@@ -3,7 +3,9 @@ package adapter
3 3
4 import (4 import (
5 "context"5 "context"
6+ "os"
6 "sync"7 "sync"
8+ "time"
7 9
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
9 domainurl "github.com/bnema/dumber/internal/domain/url"11 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -28,10 +30,17 @@ type FaviconAdapter struct {
28 internalDomain string30 internalDomain string
29 normalizedIconSize int31 normalizedIconSize int
30 getLogoBytes func() []byte32 getLogoBytes func() []byte
33+ disabled bool
31 }34 }
32 35
36+const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS"
37+
33 type warningLogFunc func(log *zerolog.Logger, err error)38 type warningLogFunc func(log *zerolog.Logger, err error)
34 39
40+type textureResult struct {
41+ texture *gdk.Texture
42+}
43+
35 // FaviconAdapterConfig holds optional configuration for FaviconAdapter.44 // FaviconAdapterConfig holds optional configuration for FaviconAdapter.
36 // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).45 // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
37 type FaviconAdapterConfig struct {46 type FaviconAdapterConfig struct {
@@ -48,6 +57,7 @@ type FaviconAdapterConfig struct {
48 // NewFaviconAdapter creates a new FaviconAdapter.57 // NewFaviconAdapter creates a new FaviconAdapter.
49 // The faviconDB can be nil if a favicon database is not available.58 // The faviconDB can be nil if a favicon database is not available.
50 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {59 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
60+ disabled := os.Getenv(disableFaviconsEnv) == "1"
51 return &FaviconAdapter{61 return &FaviconAdapter{
52 service: service,62 service: service,
53 faviconDB: faviconDB,63 faviconDB: faviconDB,
@@ -57,6 +67,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba
57 internalDomain: cfg.InternalDomain,67 internalDomain: cfg.InternalDomain,
58 normalizedIconSize: cfg.NormalizedIconSize,68 normalizedIconSize: cfg.NormalizedIconSize,
59 getLogoBytes: cfg.GetLogoBytes,69 getLogoBytes: cfg.GetLogoBytes,
70+ disabled: disabled,
60 }71 }
61 }72 }
62 73
@@ -92,18 +103,29 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
92 }103 }
93 104
94 log := logging.FromContext(ctx)105 log := logging.FromContext(ctx)
106+ if a.disabled {
107+ log.Warn().
108+ Str("env", disableFaviconsEnv).
109+ Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation")
110+ callback(nil)
111+ return
112+ }
95 113
96 // Handle internal dumb:// scheme URLs - use app logo114 // Handle internal dumb:// scheme URLs - use app logo
97 if a.isInternalURL != nil && a.isInternalURL(pageURL) {115 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
98- texture := a.getOrCreateLogoTexture()116+ callback(a.getOrCreateLogoTexture(ctx))
99- callback(texture)
100 return117 return
101 }118 }
102 119
103 domain := domainurl.ExtractDomain(pageURL)120 domain := domainurl.ExtractDomain(pageURL)
121+ log.Debug().
122+ Str("page_url", pageURL).
123+ Str("domain", domain).
124+ Msg("favicon: GetOrFetch begin")
104 125
105 // Check texture cache first126 // Check texture cache first
106 if texture := a.GetTexture(domain); texture != nil {127 if texture := a.GetTexture(domain); texture != nil {
128+ log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")
107 // Ensure sized PNG exists for CLI tools (async, idempotent)129 // Ensure sized PNG exists for CLI tools (async, idempotent)
108 go a.ensureSizedPNG(ctx, domain)130 go a.ensureSizedPNG(ctx, domain)
109 callback(texture)131 callback(texture)
@@ -111,8 +133,12 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
111 }133 }
112 134
113 // Check service cache (memory + disk) and convert to texture135 // Check service cache (memory + disk) and convert to texture
114- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {136+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
115- texture := bytesToTexture(data)137+ log.Debug().
138+ Str("domain", domain).
139+ Int("bytes", len(data)).
140+ Msg("favicon: service cache hit")
141+ texture := a.textureFromBytesOnGTK(ctx, data)
116 if texture != nil {142 if texture != nil {
117 a.setTexture(domain, texture)143 a.setTexture(domain, texture)
118 // Ensure sized PNG exists for CLI tools (async, idempotent)144 // Ensure sized PNG exists for CLI tools (async, idempotent)
@@ -122,60 +148,98 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
122 }148 }
123 }149 }
124 150
125- // Helper to invoke callback on main thread
126- invokeCallback := func(texture *gdk.Texture) {
127- cb := glib.SourceFunc(func(uintptr) bool {
128- callback(texture)
129- return false
130- })
131- glib.IdleAdd(&cb, 0)
132- }
133-
134- // Helper to fetch via service and invoke callback
135- fetchViaService := func() {
136- go func() {
137- data, err := a.service.Get(ctx, domain)
138- if err != nil || len(data) == 0 {
139- invokeCallback(nil)
140- return
141- }
142-
143- texture := bytesToTexture(data)
144- if texture != nil {
145- a.setTexture(domain, texture)
146- }
147- invokeCallback(texture)
148- }()
149- }
150-
151 // Try engine FaviconDatabase if available151 // Try engine FaviconDatabase if available
152 if a.faviconDB != nil {152 if a.faviconDB != nil {
153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154- if tex == nil {154+ a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)
155- log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
156- fetchViaService()
157- return
158- }
159- // Convert port.Texture to *gdk.Texture
160- gdkTex, ok := tex.(*gdk.Texture)
161- if !ok {
162- fetchViaService()
163- return
164- }
165- a.setTexture(domain, gdkTex)
166- invokeCallback(gdkTex)
167- a.saveFaviconToDisk(ctx, domain, gdkTex)
168 })155 })
169 return156 return
170 }157 }
171 158
172 // No WebKit DB, fetch via service directly159 // No WebKit DB, fetch via service directly
173- fetchViaService()160+ log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service")
161+ a.fetchViaService(ctx, domain, callback)
162+}
163+
164+func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
165+ log := logging.FromContext(ctx)
166+ log.Debug().
167+ Str("domain", domain).
168+ Bool("texture_nil", texture == nil).
169+ Msg("favicon: scheduling callback on GTK")
170+ cb := glib.SourceFunc(func(uintptr) bool {
171+ log.Debug().
172+ Str("domain", domain).
173+ Bool("texture_nil", texture == nil).
174+ Msg("favicon: callback executing on GTK")
175+ callback(texture)
176+ return false
177+ })
178+ glib.IdleAdd(&cb, 0)
179+}
180+
181+func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
182+ go func() {
183+ log := logging.FromContext(ctx)
184+ log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
185+ data, err := a.service.Get(ctx, domain)
186+ if err != nil || len(data) == 0 {
187+ log.Debug().
188+ Err(err).
189+ Str("domain", domain).
190+ Int("bytes", len(data)).
191+ Msg("favicon: fetchViaService empty/error result")
192+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
193+ return
194+ }
195+
196+ log.Debug().
197+ Str("domain", domain).
198+ Int("bytes", len(data)).
199+ Msg("favicon: fetchViaService fetched data")
200+ texture := a.textureFromBytesOnGTK(ctx, data)
201+ if texture != nil {
202+ a.setTexture(domain, texture)
203+ }
204+ log.Debug().
205+ Str("domain", domain).
206+ Bool("texture_nil", texture == nil).
207+ Msg("favicon: fetchViaService end")
208+ a.invokeCallbackOnGTK(ctx, domain, callback, texture)
209+ }()
210+}
211+
212+func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {
213+ log := logging.FromContext(ctx)
214+ log.Debug().
215+ Str("domain", domain).
216+ Bool("texture_nil", tex == nil).
217+ Msg("favicon: engine db callback")
218+ if tex == nil {
219+ log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
220+ a.fetchViaService(ctx, domain, callback)
221+ return
222+ }
223+ // Convert port.Texture to *gdk.Texture
224+ gdkTex, ok := tex.(*gdk.Texture)
225+ if !ok {
226+ a.fetchViaService(ctx, domain, callback)
227+ return
228+ }
229+ a.setTexture(domain, gdkTex)
230+ a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231+ a.saveFaviconToDisk(ctx, domain, gdkTex)
174 }232 }
175 233
176 // StoreFromWebKit stores a favicon texture received from WebKit signals.234 // StoreFromWebKit stores a favicon texture received from WebKit signals.
177 // Also ensures the favicon is persisted to disk cache via the service.235 // Also ensures the favicon is persisted to disk cache via the service.
178 func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {236 func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
237+ if a.disabled {
238+ logging.FromContext(ctx).Debug().
239+ Str("page_url", pageURL).
240+ Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled")
241+ return
242+ }
179 if texture == nil {243 if texture == nil {
180 return244 return
181 }245 }
@@ -271,26 +335,44 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
271 335
272 // PreloadFromCache attempts to load a favicon from cache without fetching.336 // PreloadFromCache attempts to load a favicon from cache without fetching.
273 // Returns the texture if found in memory or disk cache, nil otherwise.337 // Returns the texture if found in memory or disk cache, nil otherwise.
274-func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {338+func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture {
339+ if a.disabled {
340+ logging.FromContext(ctx).Debug().
341+ Str("page_url", pageURL).
342+ Str("env", disableFaviconsEnv).
343+ Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
344+ return nil
345+ }
275 domain := domainurl.ExtractDomain(pageURL)346 domain := domainurl.ExtractDomain(pageURL)
276 if domain == "" {347 if domain == "" {
277 return nil348 return nil
278 }349 }
350+ log := logging.FromContext(ctx)
351+ log.Debug().
352+ Str("page_url", pageURL).
353+ Str("domain", domain).
354+ Msg("favicon: PreloadFromCache begin")
279 355
280 // Check texture cache356 // Check texture cache
281 if texture := a.GetTexture(domain); texture != nil {357 if texture := a.GetTexture(domain); texture != nil {
358+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")
282 return texture359 return texture
283 }360 }
284 361
285 // Check service cache (memory + disk)362 // Check service cache (memory + disk)
286- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {363+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
287- texture := bytesToTexture(data)364+ log.Debug().
365+ Str("domain", domain).
366+ Int("bytes", len(data)).
367+ Msg("favicon: PreloadFromCache service hit")
368+ texture := a.textureFromBytesOnGTK(ctx, data)
288 if texture != nil {369 if texture != nil {
289 a.setTexture(domain, texture)370 a.setTexture(domain, texture)
290 return texture371 return texture
291 }372 }
292 }373 }
293 374
375+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss")
294 return nil376 return nil
295 }377 }
296 378
@@ -402,7 +484,7 @@ func (a *FaviconAdapter) logWarningDedup(
402 484
403 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.485 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
404 // The texture is cached under the internalDomain key.486 // The texture is cached under the internalDomain key.
405-func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {487+func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture {
406 if a.internalDomain == "" || a.getLogoBytes == nil {488 if a.internalDomain == "" || a.getLogoBytes == nil {
407 return nil489 return nil
408 }490 }
@@ -414,13 +496,45 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
414 496
415 // Create texture from logo bytes497 // Create texture from logo bytes
416 logoBytes := a.getLogoBytes()498 logoBytes := a.getLogoBytes()
417- texture := bytesToTexture(logoBytes)499+ texture := a.textureFromBytesOnGTK(ctx, logoBytes)
418 if texture != nil {500 if texture != nil {
419 a.setTexture(a.internalDomain, texture)501 a.setTexture(a.internalDomain, texture)
420 }502 }
421 return texture503 return texture
422 }504 }
423 505
506+func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture {
507+ if len(data) == 0 {
508+ return nil
509+ }
510+ log := logging.FromContext(ctx)
511+ log.Debug().
512+ Int("bytes", len(data)).
513+ Msg("favicon: textureFromBytesOnGTK begin")
514+
515+ glibCtx := glib.MainContextDefault()
516+ if glibCtx != nil && glibCtx.IsOwner() {
517+ texture := bytesToTexture(data)
518+ log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
519+ return texture
520+ }
521+
522+ resultCh := make(chan textureResult, 1)
523+ cb := glib.SourceOnceFunc(func(_ uintptr) {
524+ log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK")
525+ resultCh <- textureResult{texture: bytesToTexture(data)}
526+ })
527+ glib.IdleAddOnce(&cb, 0)
528+ select {
529+ case result := <-resultCh:
530+ log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
531+ return result.texture
532+ case <-time.After(5 * time.Second):
533+ log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock")
534+ return nil
535+ }
536+}
537+
424 // bytesToTexture converts raw favicon bytes to a GDK texture.538 // bytesToTexture converts raw favicon bytes to a GDK texture.
425 func bytesToTexture(data []byte) *gdk.Texture {539 func bytesToTexture(data []byte) *gdk.Texture {
426 if len(data) == 0 {540 if len(data) == 0 {
M internal/ui/app.go +19 -4
@@ -50,6 +50,15 @@ func gtkApplicationFlags() gio.ApplicationFlags {
50 return gio.GApplicationNonUniqueValue50 return gio.GApplicationNonUniqueValue
51 }51 }
52 52
53+var adwaitaInitOnce sync.Once
54+
55+// EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
56+func EnsureAdwaitaInitialized() {
57+ adwaitaInitOnce.Do(func() {
58+ adw.Init()
59+ })
60+}
61+
53 // App wraps the GTK Application and manages the browser lifecycle.62 // App wraps the GTK Application and manages the browser lifecycle.
54 type App struct {63 type App struct {
55 deps *Dependencies64 deps *Dependencies
@@ -195,6 +204,7 @@ func New(deps *Dependencies) (*App, error) {
195 })204 })
196 glib.IdleAdd(&cb, 0)205 glib.IdleAdd(&cb, 0)
197 },206 },
207+ HandlerDeps: deps.HandlerDeps,
198 }); err != nil {208 }); err != nil {
199 return nil, err209 return nil, err
200 }210 }
@@ -210,7 +220,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
210 220
211 // Initialize libadwaita once (required before using StyleManager).221 // Initialize libadwaita once (required before using StyleManager).
212 // This also initializes GTK implicitly.222 // This also initializes GTK implicitly.
213- adw.Init()223+ EnsureAdwaitaInitialized()
214 logging.Trace().Mark("gtk_init")224 logging.Trace().Mark("gtk_init")
215 225
216 // Mark adwaita detector as available now that adw.Init() is complete.226 // Mark adwaita detector as available now that adw.Init() is complete.
@@ -371,12 +381,19 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
371 }381 }
372 382
373 func (a *App) createMainWindow(ctx context.Context) error {383 func (a *App) createMainWindow(ctx context.Context) error {
384+ log := logging.FromContext(ctx)
374 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)385 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
375 if err != nil {386 if err != nil {
376 return err387 return err
377 }388 }
378 a.mainWindow = mainWindow389 a.mainWindow = mainWindow
379 390
391+ closeRequestCb := func(_ gtk.Window) bool {
392+ log.Info().Msg("main window close requested")
393+ return false
394+ }
395+ a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
396+
380 // Create permission popup and dialog presenter397 // Create permission popup and dialog presenter
381 if a.deps != nil && a.deps.PermissionUC != nil {398 if a.deps != nil && a.deps.PermissionUC != nil {
382 uiScale := 1.0399 uiScale := 1.0
@@ -927,12 +944,10 @@ func (a *App) initSessionManager(ctx context.Context) {
927 listSessionsUC = usecase.NewListSessionsUseCase(944 listSessionsUC = usecase.NewListSessionsUseCase(
928 a.deps.SessionRepo,945 a.deps.SessionRepo,
929 a.deps.SessionStateRepo,946 a.deps.SessionStateRepo,
930- a.deps.Config.Logging.LogDir,
931 )947 )
932 deleteSessionUC = usecase.NewDeleteSessionUseCase(948 deleteSessionUC = usecase.NewDeleteSessionUseCase(
933 a.deps.SessionStateRepo,949 a.deps.SessionStateRepo,
934 a.deps.SessionRepo,950 a.deps.SessionRepo,
935- a.deps.Config.Logging.LogDir,
936 )951 )
937 }952 }
938 953
@@ -2564,7 +2579,7 @@ func (a *App) ensureFloatingSession(
2564 // regular workspace panes. This allows the content coordinator's2579 // regular workspace panes. This allows the content coordinator's
2565 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find2580 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2566 // and update the floating pane automatically.2581 // and update the floating pane automatically.
2567- pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)2582+ pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
2568 pv.SetActive(true)2583 pv.SetActive(true)
2569 // Hide loading skeleton — floating panes have their own themed container.2584 // Hide loading skeleton — floating panes have their own themed container.
2570 pv.HideLoadingSkeleton()2585 pv.HideLoadingSkeleton()
M internal/ui/component/pane_view.go +34 -2
@@ -6,6 +6,7 @@ import (
6 "sync"6 "sync"
7 7
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/dumber/internal/ui/input"10 "github.com/bnema/dumber/internal/ui/input"
10 "github.com/bnema/dumber/internal/ui/layout"11 "github.com/bnema/dumber/internal/ui/layout"
11 )12 )
@@ -18,6 +19,7 @@ const (
18 // PaneView is a container for a single WebView with active state indication.19 // PaneView is a container for a single WebView with active state indication.
19 // It uses an overlay to display a border around the active pane.20 // It uses an overlay to display a border around the active pane.
20 type PaneView struct {21 type PaneView struct {
22+ ctx context.Context
21 factory layout.WidgetFactory23 factory layout.WidgetFactory
22 overlay layout.OverlayWidget24 overlay layout.OverlayWidget
23 webViewWidget layout.Widget // The actual WebView widget25 webViewWidget layout.Widget // The actual WebView widget
@@ -40,7 +42,7 @@ type PaneView struct {
40 }42 }
41 43
42 // NewPaneView creates a new pane view container for a WebView widget.44 // NewPaneView creates a new pane view container for a WebView widget.
43-func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {45+func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
44 overlay := factory.NewOverlay()46 overlay := factory.NewOverlay()
45 overlay.SetHexpand(true)47 overlay.SetHexpand(true)
46 overlay.SetVexpand(true)48 overlay.SetVexpand(true)
@@ -81,6 +83,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
81 // issues with the internal progress gizmo before the overlay is realized83 // issues with the internal progress gizmo before the overlay is realized
82 84
83 return &PaneView{85 return &PaneView{
86+ ctx: ctx,
84 factory: factory,87 factory: factory,
85 overlay: overlay,88 overlay: overlay,
86 webViewWidget: webViewWidget,89 webViewWidget: webViewWidget,
@@ -344,7 +347,16 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
344 return pv.progressBar347 return pv.progressBar
345 }348 }
346 349
347- pb := NewProgressBar(pv.factory)350+ ctx := pv.ctx
351+ if ctx == nil {
352+ ctx = context.Background()
353+ }
354+ logging.FromContext(ctx).
355+ Debug().
356+ Str("pane_id", string(pv.paneID)).
357+ Msg("creating progress bar")
358+
359+ pb := NewProgressBar(pv.ctx, pv.factory)
348 pv.overlay.AddOverlay(pb.Widget())360 pv.overlay.AddOverlay(pb.Widget())
349 pv.overlay.SetClipOverlay(pb.Widget(), false)361 pv.overlay.SetClipOverlay(pb.Widget(), false)
350 pv.overlay.SetMeasureOverlay(pb.Widget(), false)362 pv.overlay.SetMeasureOverlay(pb.Widget(), false)
@@ -357,6 +369,15 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
357 func (pv *PaneView) SetLoadProgress(progress float64) {369 func (pv *PaneView) SetLoadProgress(progress float64) {
358 pv.mu.Lock()370 pv.mu.Lock()
359 pb := pv.ensureProgressBar()371 pb := pv.ensureProgressBar()
372+ ctx := pv.ctx
373+ if ctx == nil {
374+ ctx = context.Background()
375+ }
376+ logging.FromContext(ctx).
377+ Debug().
378+ Str("pane_id", string(pv.paneID)).
379+ Float64("progress", progress).
380+ Msg("setting load progress")
360 pv.mu.Unlock()381 pv.mu.Unlock()
361 382
362 pb.SetProgress(progress)383 pb.SetProgress(progress)
@@ -365,7 +386,18 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
365 // SetLoading shows or hides the progress bar.386 // SetLoading shows or hides the progress bar.
366 func (pv *PaneView) SetLoading(loading bool) {387 func (pv *PaneView) SetLoading(loading bool) {
367 pv.mu.Lock()388 pv.mu.Lock()
389+ hadProgressBar := pv.progressBar != nil
368 pb := pv.ensureProgressBar()390 pb := pv.ensureProgressBar()
391+ ctx := pv.ctx
392+ if ctx == nil {
393+ ctx = context.Background()
394+ }
395+ logging.FromContext(ctx).
396+ Debug().
397+ Str("pane_id", string(pv.paneID)).
398+ Bool("loading", loading).
399+ Bool("progress_bar_existed", hadProgressBar).
400+ Msg("setting loading state")
369 pv.mu.Unlock()401 pv.mu.Unlock()
370 402
371 if loading {403 if loading {
M internal/ui/component/pane_view_test.go +24 -23
@@ -1,6 +1,7 @@
1 package component_test1 package component_test
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -47,7 +48,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
47 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()48 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
48 49
49 // Act50 // Act
50- pv := component.NewPaneView(mockFactory, paneID, mockWebView)51+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
51 52
52 // Assert53 // Assert
53 require.NotNil(t, pv)54 require.NotNil(t, pv)
@@ -88,7 +89,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
88 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()89 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
89 90
90 // Act91 // Act
91- pv := component.NewPaneView(mockFactory, paneID, nil)92+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, nil)
92 93
93 // Assert94 // Assert
94 require.NotNil(t, pv)95 require.NotNil(t, pv)
@@ -104,7 +105,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) {
104 105
105 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)106 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
106 107
107- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)108+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
108 109
109 // Expect CSS class to be added110 // Expect CSS class to be added
110 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()111 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -125,7 +126,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) {
125 126
126 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)127 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
127 128
128- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)129+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
129 130
130 // First activate131 // First activate
131 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()132 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -150,7 +151,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) {
150 151
151 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)152 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
152 153
153- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)154+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
154 155
155 // Act - setting false when already false should not call RemoveCssClass156 // Act - setting false when already false should not call RemoveCssClass
156 pv.SetActive(false)157 pv.SetActive(false)
@@ -169,7 +170,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) {
169 paneID := entity.PaneID("test-pane-123")170 paneID := entity.PaneID("test-pane-123")
170 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)171 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
171 172
172- pv := component.NewPaneView(mockFactory, paneID, mockWebView)173+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
173 174
174 // Act175 // Act
175 result := pv.PaneID()176 result := pv.PaneID()
@@ -187,7 +188,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) {
187 188
188 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)189 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
189 190
190- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)191+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
191 192
192 // Act193 // Act
193 result := pv.WebViewWidget()194 result := pv.WebViewWidget()
@@ -206,7 +207,7 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
206 207
207 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)208 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
208 209
209- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)210+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockOldWebView)
210 211
211 // Expect removal of old widget and addition of new212 // Expect removal of old widget and addition of new
212 mockOverlay.EXPECT().SetChild(nil).Once()213 mockOverlay.EXPECT().SetChild(nil).Once()
@@ -229,7 +230,7 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
229 230
230 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)231 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
231 232
232- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)233+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
233 234
234 // Expect only setting new child (no removal since old was nil)235 // Expect only setting new child (no removal since old was nil)
235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()236 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
@@ -251,7 +252,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) {
251 252
252 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)253 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
253 254
254- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)255+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
255 256
256 mockWebView.EXPECT().GrabFocus().Return(true).Once()257 mockWebView.EXPECT().GrabFocus().Return(true).Once()
257 258
@@ -270,7 +271,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
270 271
271 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)272 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
272 273
273- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)274+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
274 275
275 // Act276 // Act
276 result := pv.GrabFocus()277 result := pv.GrabFocus()
@@ -288,7 +289,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) {
288 289
289 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)290 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
290 291
291- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)292+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
292 293
293 mockWebView.EXPECT().HasFocus().Return(true).Once()294 mockWebView.EXPECT().HasFocus().Return(true).Once()
294 295
@@ -307,7 +308,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
307 308
308 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)309 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
309 310
310- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)311+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
311 312
312 // Act313 // Act
313 result := pv.HasFocus()314 result := pv.HasFocus()
@@ -325,7 +326,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) {
325 326
326 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)327 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
327 328
328- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)329+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
329 330
330 // Act331 // Act
331 result := pv.Widget()332 result := pv.Widget()
@@ -343,7 +344,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
343 344
344 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)345 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
345 346
346- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)347+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
347 348
348 // Act349 // Act
349 result := pv.Overlay()350 result := pv.Overlay()
@@ -361,7 +362,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) {
361 362
362 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)363 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
363 364
364- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)365+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
365 366
366 mockOverlay.EXPECT().Show().Once()367 mockOverlay.EXPECT().Show().Once()
367 368
@@ -380,7 +381,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) {
380 381
381 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)382 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
382 383
383- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)384+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
384 385
385 mockOverlay.EXPECT().Hide().Once()386 mockOverlay.EXPECT().Hide().Once()
386 387
@@ -399,7 +400,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) {
399 400
400 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)401 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
401 402
402- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)403+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
403 404
404 mockOverlay.EXPECT().SetVisible(true).Once()405 mockOverlay.EXPECT().SetVisible(true).Once()
405 406
@@ -418,7 +419,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) {
418 419
419 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)420 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
420 421
421- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)422+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
422 423
423 mockOverlay.EXPECT().IsVisible().Return(true).Once()424 mockOverlay.EXPECT().IsVisible().Return(true).Once()
424 425
@@ -438,7 +439,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
438 439
439 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)440 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
440 441
441- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)442+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
442 443
443 mockOverlay.EXPECT().AddCssClass("custom-class").Once()444 mockOverlay.EXPECT().AddCssClass("custom-class").Once()
444 445
@@ -457,7 +458,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
457 458
458 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)459 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
459 460
460- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)461+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
461 462
462 mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()463 mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()
463 464
@@ -476,7 +477,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) {
476 477
477 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)478 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
478 479
479- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)480+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
480 481
481 callback := func(paneID entity.PaneID) {}482 callback := func(paneID entity.PaneID) {}
482 483
@@ -496,7 +497,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
496 497
497 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)498 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
498 499
499- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)500+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
500 501
501 callback := func(paneID entity.PaneID) {}502 callback := func(paneID entity.PaneID) {}
502 503
M internal/ui/component/progress_bar.go +58 -1
@@ -2,8 +2,10 @@
2 package component2 package component
3 3
4 import (4 import (
5+ "context"
5 "sync"6 "sync"
6 7
8+ "github.com/bnema/dumber/internal/logging"
7 "github.com/bnema/puregotk/v4/glib"9 "github.com/bnema/puregotk/v4/glib"
8 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
9 11
@@ -24,6 +26,7 @@ const (
24 // Implements smooth animation by incrementing towards the target value.26 // Implements smooth animation by incrementing towards the target value.
25 // Includes a 30-second timeout to auto-hide if the page load stalls.27 // Includes a 30-second timeout to auto-hide if the page load stalls.
26 type ProgressBar struct {28 type ProgressBar struct {
29+ ctx context.Context
27 progressBar layout.ProgressBarWidget30 progressBar layout.ProgressBarWidget
28 31
29 visible bool32 visible bool
@@ -36,7 +39,7 @@ type ProgressBar struct {
36 }39 }
37 40
38 // NewProgressBar creates a new progress bar component using the widget factory.41 // NewProgressBar creates a new progress bar component using the widget factory.
39-func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {42+func NewProgressBar(ctx context.Context, factory layout.WidgetFactory) *ProgressBar {
40 progressBar := factory.NewProgressBar()43 progressBar := factory.NewProgressBar()
41 44
42 // Add "osd" class for on-screen-display overlay styling (like Epiphany)45 // Add "osd" class for on-screen-display overlay styling (like Epiphany)
@@ -63,6 +66,7 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
63 progressBar.SetVisible(false)66 progressBar.SetVisible(false)
64 67
65 return &ProgressBar{68 return &ProgressBar{
69+ ctx: ctx,
66 progressBar: progressBar,70 progressBar: progressBar,
67 visible: false,71 visible: false,
68 currentValue: 0,72 currentValue: 0,
@@ -76,6 +80,19 @@ func (pb *ProgressBar) SetProgress(progress float64) {
76 pb.mu.Lock()80 pb.mu.Lock()
77 defer pb.mu.Unlock()81 defer pb.mu.Unlock()
78 82
83+ incomingProgress := progress
84+ ctx := pb.ctx
85+ if ctx == nil {
86+ ctx = context.Background()
87+ }
88+ logging.FromContext(ctx).
89+ Debug().
90+ Float64("incoming_progress", incomingProgress).
91+ Float64("current_value", pb.currentValue).
92+ Float64("target_value", pb.targetValue).
93+ Bool("visible", pb.visible).
94+ Msg("setting progress")
95+
79 // Clamp progress to valid range96 // Clamp progress to valid range
80 if progress < 0 {97 if progress < 0 {
81 progress = 098 progress = 0
@@ -131,18 +148,49 @@ func (pb *ProgressBar) startAnimation() {
131 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)148 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
132 }149 }
133 150
151+// initialProgressFraction is the fraction set when the progress bar first
152+// appears so the user gets immediate visual feedback that loading has begun.
153+// CEF may not fire OnLoadingProgressChange for hundreds of milliseconds
154+// during cross-site process swaps, leaving the bar at 0% (invisible).
155+const initialProgressFraction = 0.1
156+
134 // Show makes the progress bar visible and starts the auto-hide timeout.157 // Show makes the progress bar visible and starts the auto-hide timeout.
135 func (pb *ProgressBar) Show() {158 func (pb *ProgressBar) Show() {
136 pb.mu.Lock()159 pb.mu.Lock()
137 defer pb.mu.Unlock()160 defer pb.mu.Unlock()
138 161
162+ ctx := pb.ctx
163+ if ctx == nil {
164+ ctx = context.Background()
165+ }
166+ logging.FromContext(ctx).
167+ Debug().
168+ Bool("visible", pb.visible).
169+ Float64("current_value", pb.currentValue).
170+ Float64("target_value", pb.targetValue).
171+ Msg("progress bar show before")
172+
139 if !pb.visible {173 if !pb.visible {
140 pb.visible = true174 pb.visible = true
175+ // Set an initial non-zero fraction so the bar is visually noticeable
176+ // immediately. Without this, the bar is technically visible but
177+ // renders as empty (0% fill) until the first progress callback,
178+ // which in CEF can be delayed during cross-site process swaps.
179+ pb.currentValue = initialProgressFraction
180+ pb.targetValue = initialProgressFraction
181+ pb.progressBar.SetFraction(initialProgressFraction)
141 pb.progressBar.SetVisible(true)182 pb.progressBar.SetVisible(true)
142 }183 }
143 184
144 // Reset timeout timer on every Show call185 // Reset timeout timer on every Show call
145 pb.resetTimeout()186 pb.resetTimeout()
187+
188+ logging.FromContext(pb.ctx).
189+ Debug().
190+ Bool("visible", pb.visible).
191+ Float64("current_value", pb.currentValue).
192+ Float64("target_value", pb.targetValue).
193+ Msg("progress bar show after")
146 }194 }
147 195
148 // resetTimeout cancels any existing timeout and starts a new one.196 // resetTimeout cancels any existing timeout and starts a new one.
@@ -181,6 +229,15 @@ func (pb *ProgressBar) Hide() {
181 // hideInternal performs the actual hide operation.229 // hideInternal performs the actual hide operation.
182 // Must be called with lock held.230 // Must be called with lock held.
183 func (pb *ProgressBar) hideInternal() {231 func (pb *ProgressBar) hideInternal() {
232+ ctx := pb.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ logging.FromContext(ctx).
237+ Debug().
238+ Bool("visible", pb.visible).
239+ Msg("progress bar hide before")
240+
184 if pb.visible {241 if pb.visible {
185 pb.visible = false242 pb.visible = false
186 pb.progressBar.SetVisible(false)243 pb.progressBar.SetVisible(false)
M internal/ui/component/workspace_view.go +1 -1
@@ -86,7 +86,7 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
86 86
87 // Create PaneView without WebView widget for now87 // Create PaneView without WebView widget for now
88 // WebView will be attached later by the application layer88 // WebView will be attached later by the application layer
89- pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)89+ pv := NewPaneView(a.ctx, a.wv.factory, node.Pane.ID, nil)
90 90
91 // Store in map for later lookup91 // Store in map for later lookup
92 // Note: Caller (SetWorkspace) already holds the lock, so we access directly92 // Note: Caller (SetWorkspace) already holds the lock, so we access directly
M internal/ui/coordinator/content/appearance.go +10 -6
@@ -72,23 +72,27 @@ func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
72 try {72 try {
73 var cssText = %s;73 var cssText = %s;
74 var prefersDark = %t;74 var prefersDark = %t;
75+ var root = document.documentElement;
75 76
76- // Keep the global flag in sync77+ // Keep the global flags in sync for both WebKit and CEF-backed pages.
77 window.__dumber_gtk_prefers_dark = prefersDark;78 window.__dumber_gtk_prefers_dark = prefersDark;
79+ window.__dumber_cef_prefers_dark = prefersDark;
78 80
79 // Update dark/light class81 // Update dark/light class
80 if (prefersDark) {82 if (prefersDark) {
81- document.documentElement.classList.add('dark');83+ root.classList.add('dark');
82- document.documentElement.classList.remove('light');84+ root.classList.remove('light');
83 } else {85 } else {
84- document.documentElement.classList.add('light');86+ root.classList.add('light');
85- document.documentElement.classList.remove('dark');87+ root.classList.remove('dark');
86 }88 }
89+ root.style.colorScheme = prefersDark ? 'dark' : 'light';
87 90
88 // Update or insert theme style91 // Update or insert theme style
89- var style = document.querySelector('style[data-dumber-theme-vars]');92+ var style = document.getElementById('dumber-theme-vars') || document.querySelector('style[data-dumber-theme-vars]');
90 if (!style) {93 if (!style) {
91 style = document.createElement('style');94 style = document.createElement('style');
95+ style.id = 'dumber-theme-vars';
92 style.setAttribute('data-dumber-theme-vars', '');96 style.setAttribute('data-dumber-theme-vars', '');
93 (document.head || document.documentElement).appendChild(style);97 (document.head || document.documentElement).appendChild(style);
94 }98 }
M internal/ui/coordinator/content/content_reveal_test.go +34 -0
@@ -8,7 +8,9 @@ import (
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/ui/component"
12 )14 )
13 15
14 // TestPendingReveal tests the pending reveal state management.16 // TestPendingReveal tests the pending reveal state management.
@@ -108,6 +110,38 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
108 assert.False(t, c.pendingReveal[paneID])110 assert.False(t, c.pendingReveal[paneID])
109 }111 }
110 112
113+func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {
114+ paneID := entity.PaneID("pane-1")
115+ shownCount := 0
116+ var shownPane entity.PaneID
117+
118+ wv := mocks.NewMockWebView(t)
119+ wv.EXPECT().URI().Return("https://example.com")
120+ wv.EXPECT().ResetBackgroundToDefault()
121+ wv.EXPECT().Title().Return("")
122+ wv.EXPECT().IsDestroyed().Return(false).Maybe()
123+
124+ c := &Coordinator{
125+ pendingReveal: make(map[entity.PaneID]bool),
126+ webViews: map[entity.PaneID]port.WebView{
127+ paneID: wv,
128+ },
129+ paneTitles: make(map[entity.PaneID]string),
130+ navOrigins: make(map[entity.PaneID]string),
131+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
132+ onWebViewShown: func(id entity.PaneID) {
133+ shownCount++
134+ shownPane = id
135+ },
136+ }
137+ c.pendingReveal[paneID] = true
138+
139+ c.onLoadCommitted(context.Background(), paneID, wv)
140+
141+ assert.Equal(t, 1, shownCount)
142+ assert.Equal(t, paneID, shownPane)
143+}
144+
111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {145 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112 c := &Coordinator{146 c := &Coordinator{
113 pendingReveal: make(map[entity.PaneID]bool),147 pendingReveal: make(map[entity.PaneID]bool),
M internal/ui/coordinator/content/coordinator.go +5 -0
@@ -14,10 +14,12 @@ import (
14 "github.com/bnema/dumber/internal/ui/component"14 "github.com/bnema/dumber/internal/ui/component"
15 "github.com/bnema/dumber/internal/ui/input"15 "github.com/bnema/dumber/internal/ui/input"
16 "github.com/bnema/dumber/internal/ui/layout"16 "github.com/bnema/dumber/internal/ui/layout"
17+ "github.com/rs/zerolog"
17 )18 )
18 19
19 // Coordinator manages WebView lifecycle, title tracking, and content attachment.20 // Coordinator manages WebView lifecycle, title tracking, and content attachment.
20 type Coordinator struct {21 type Coordinator struct {
22+ logger zerolog.Logger
21 pool port.WebViewPool23 pool port.WebViewPool
22 widgetFactory layout.WidgetFactory24 widgetFactory layout.WidgetFactory
23 faviconAdapter *adapter.FaviconAdapter25 faviconAdapter *adapter.FaviconAdapter
@@ -83,6 +85,7 @@ type Coordinator struct {
83 factory port.WebViewFactory85 factory port.WebViewFactory
84 popupConfig *entity.PopupBehaviorConfig86 popupConfig *entity.PopupBehaviorConfig
85 pendingPopups map[port.WebViewID]*PendingPopup87 pendingPopups map[port.WebViewID]*PendingPopup
88+ namedPopups map[namedPopupKey]*namedPopupState
86 popupOAuth map[port.WebViewID]*popupOAuthState89 popupOAuth map[port.WebViewID]*popupOAuthState
87 popupRefresh map[entity.PaneID]*time.Timer90 popupRefresh map[entity.PaneID]*time.Timer
88 popupMu sync.RWMutex91 popupMu sync.RWMutex
@@ -151,6 +154,7 @@ func NewCoordinator(
151 log.Debug().Msg("creating content coordinator")154 log.Debug().Msg("creating content coordinator")
152 155
153 return &Coordinator{156 return &Coordinator{
157+ logger: log.With().Str("component", "content-coordinator").Logger(),
154 pool: pool,158 pool: pool,
155 injector: injector,159 injector: injector,
156 widgetFactory: widgetFactory,160 widgetFactory: widgetFactory,
@@ -165,6 +169,7 @@ func NewCoordinator(
165 pendingThemePanes: make(map[entity.PaneID]bool),169 pendingThemePanes: make(map[entity.PaneID]bool),
166 getActiveWS: getActiveWS,170 getActiveWS: getActiveWS,
167 pendingPopups: make(map[port.WebViewID]*PendingPopup),171 pendingPopups: make(map[port.WebViewID]*PendingPopup),
172+ namedPopups: make(map[namedPopupKey]*namedPopupState),
168 popupOAuth: make(map[port.WebViewID]*popupOAuthState),173 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
169 popupRefresh: make(map[entity.PaneID]*time.Timer),174 popupRefresh: make(map[entity.PaneID]*time.Timer),
170 }175 }
M internal/ui/coordinator/content/navigation.go +17 -9
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 urlutil "github.com/bnema/dumber/internal/domain/url"10 urlutil "github.com/bnema/dumber/internal/domain/url"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/puregotk/v4/gtk"13 "github.com/bnema/puregotk/v4/gtk"
13 )14 )
14 15
@@ -77,9 +78,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
77 }78 }
78 79
79 c.markPendingReveal(paneID)80 c.markPendingReveal(paneID)
80- if wv.EstimatedProgress() > 0 {81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82- }
83 82
84 // Update domain model with current URI for session snapshots83 // Update domain model with current URI for session snapshots
85 c.updatePaneURI(paneID, uri)84 c.updatePaneURI(paneID, uri)
@@ -188,11 +187,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
188 })187 })
189 188
190 _, wsView := c.getActiveWS()189 _, wsView := c.getActiveWS()
190+ var paneView *component.PaneView
191+ if wsView != nil {
192+ paneView = wsView.GetPaneView(paneID)
193+ }
194+
191 if wsView == nil {195 if wsView == nil {
192 return196 return
193 }197 }
194-
195- paneView := wsView.GetPaneView(paneID)
196 if paneView != nil {198 if paneView != nil {
197 paneView.SetLoading(true)199 paneView.SetLoading(true)
198 }200 }
@@ -201,11 +203,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
201 // onLoadFinished hides the progress bar when page loading completes.203 // onLoadFinished hides the progress bar when page loading completes.
202 func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {204 func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
203 _, wsView := c.getActiveWS()205 _, wsView := c.getActiveWS()
206+ var paneView *component.PaneView
207+ if wsView != nil {
208+ paneView = wsView.GetPaneView(paneID)
209+ }
210+
204 if wsView == nil {211 if wsView == nil {
205 return212 return
206 }213 }
207-
208- paneView := wsView.GetPaneView(paneID)
209 if paneView != nil {214 if paneView != nil {
210 paneView.SetLoading(false)215 paneView.SetLoading(false)
211 }216 }
@@ -225,11 +230,14 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
225 }230 }
226 231
227 _, wsView := c.getActiveWS()232 _, wsView := c.getActiveWS()
233+ var paneView *component.PaneView
234+ if wsView != nil {
235+ paneView = wsView.GetPaneView(paneID)
236+ }
237+
228 if wsView == nil {238 if wsView == nil {
229 return239 return
230 }240 }
231-
232- paneView := wsView.GetPaneView(paneID)
233 if paneView != nil {241 if paneView != nil {
234 paneView.SetLoadProgress(progress)242 paneView.SetLoadProgress(progress)
235 }243 }
M internal/ui/coordinator/content/popup.go +155 -13
@@ -79,6 +79,82 @@ type PendingPopup struct {
79 CreatedAt time.Time79 CreatedAt time.Time
80 }80 }
81 81
82+type namedPopupKey struct {
83+ ParentPaneID entity.PaneID
84+ FrameName string
85+}
86+
87+type namedPopupState struct {
88+ WebView port.WebView
89+}
90+
91+func isReusableNamedPopupFrame(frameName string) bool {
92+ return frameName != "" && frameName != "_blank"
93+}
94+
95+func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
96+ if !isReusableNamedPopupFrame(frameName) {
97+ return nil, false
98+ }
99+
100+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
101+
102+ c.popupMu.RLock()
103+ state, ok := c.namedPopups[key]
104+ c.popupMu.RUnlock()
105+ if !ok || state == nil || state.WebView == nil {
106+ return nil, false
107+ }
108+
109+ if state.WebView.IsDestroyed() {
110+ c.popupMu.Lock()
111+ if current, ok := c.namedPopups[key]; ok && current == state {
112+ delete(c.namedPopups, key)
113+ }
114+ c.popupMu.Unlock()
115+ return nil, false
116+ }
117+
118+ return state, true
119+}
120+
121+func (c *Coordinator) storeReusableNamedPopup(
122+ parentPaneID entity.PaneID,
123+ frameName string,
124+ wv port.WebView,
125+) {
126+ if !isReusableNamedPopupFrame(frameName) || wv == nil {
127+ return
128+ }
129+
130+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
131+
132+ c.popupMu.Lock()
133+ if c.namedPopups == nil {
134+ c.namedPopups = make(map[namedPopupKey]*namedPopupState)
135+ }
136+ c.namedPopups[key] = &namedPopupState{WebView: wv}
137+ c.popupMu.Unlock()
138+}
139+
140+func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
141+ c.popupMu.Lock()
142+ if pending, ok := c.pendingPopups[popupID]; ok && pending != nil {
143+ pending.TargetURI = targetURI
144+ }
145+ c.popupMu.Unlock()
146+}
147+
148+func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
149+ c.popupMu.Lock()
150+ for key, state := range c.namedPopups {
151+ if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
152+ delete(c.namedPopups, key)
153+ }
154+ }
155+ c.popupMu.Unlock()
156+}
157+
82 // InsertPopupInput contains the data needed to insert a popup into the workspace.158 // InsertPopupInput contains the data needed to insert a popup into the workspace.
83 type InsertPopupInput struct {159 type InsertPopupInput struct {
84 // ParentPaneID is the pane that spawned this popup.160 // ParentPaneID is the pane that spawned this popup.
@@ -224,8 +300,12 @@ func (c *Coordinator) handlePopupCreate(
224 return nil300 return nil
225 }301 }
226 302
227- // Create related WebView for session sharing (created hidden)
228 parentID := parentWV.ID()303 parentID := parentWV.ID()
304+ if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
305+ return reused
306+ }
307+
308+ // Create related WebView for session sharing (created hidden)
229 popupWV, err := c.factory.CreateRelated(ctx, parentID)309 popupWV, err := c.factory.CreateRelated(ctx, parentID)
230 if err != nil {310 if err != nil {
231 log.Error().Err(err).Msg("failed to create related webview for popup")311 log.Error().Err(err).Msg("failed to create related webview for popup")
@@ -246,10 +326,53 @@ func (c *Coordinator) handlePopupCreate(
246 // Create popup pane entity326 // Create popup pane entity
247 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)327 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
248 328
249- // Check OAuth configuration329+ return c.finishPopupCreate(ctx, parentPaneID, parentID, popupID, popupWV, popupPane, paneID, popupType, behavior, placement, req)
330+}
331+
332+func (c *Coordinator) reuseNamedPopup(
333+ ctx context.Context,
334+ parentPaneID entity.PaneID,
335+ frameName string,
336+ targetURI string,
337+) (port.WebView, bool) {
338+ log := logging.FromContext(ctx)
339+
340+ if existing, ok := c.lookupReusableNamedPopup(parentPaneID, frameName); ok {
341+ c.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
342+ if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
343+ log.Warn().Err(err).
344+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
345+ Msg("failed to load target URI in reused popup")
346+ }
347+ log.Info().
348+ Str("parent_pane", string(parentPaneID)).
349+ Str("frame_name", frameName).
350+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
351+ Msg("reused named popup")
352+ return existing.WebView, true
353+ }
354+
355+ return nil, false
356+}
357+
358+func (c *Coordinator) finishPopupCreate(
359+ ctx context.Context,
360+ parentPaneID entity.PaneID,
361+ parentID port.WebViewID,
362+ popupID port.WebViewID,
363+ popupWV port.WebView,
364+ popupPane *entity.Pane,
365+ paneID entity.PaneID,
366+ popupType PopupType,
367+ behavior entity.PopupBehavior,
368+ placement string,
369+ req port.PopupRequest,
370+) port.WebView {
371+ log := logging.FromContext(ctx)
250 hasConfig := c.popupConfig != nil372 hasConfig := c.popupConfig != nil
251 oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose373 oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
252 isOAuth := IsOAuthURL(req.TargetURI)374 isOAuth := IsOAuthURL(req.TargetURI)
375+
253 log.Debug().376 log.Debug().
254 Bool("has_config", hasConfig).377 Bool("has_config", hasConfig).
255 Bool("oauth_enabled", oauthEnabled).378 Bool("oauth_enabled", oauthEnabled).
@@ -279,6 +402,7 @@ func (c *Coordinator) handlePopupCreate(
279 402
280 // Register WebView in our map (after successful insertion)403 // Register WebView in our map (after successful insertion)
281 c.setWebViewLocked(paneID, popupWV)404 c.setWebViewLocked(paneID, popupWV)
405+ c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
282 406
283 // Setup standard callbacks (after successful insertion to avoid leak)407 // Setup standard callbacks (after successful insertion to avoid leak)
284 c.setupWebViewCallbacks(ctx, paneID, popupWV)408 c.setupWebViewCallbacks(ctx, paneID, popupWV)
@@ -316,17 +440,25 @@ func (c *Coordinator) handlePopupCreate(
316 pc.SetOnClose(func() {440 pc.SetOnClose(func() {
317 c.handlePopupClose(ctx, popupID)441 c.handlePopupClose(ctx, popupID)
318 })442 })
443+ log.Info().
444+ Uint64("popup_id", uint64(popupID)).
445+ Str("pane_id", string(paneID)).
446+ Str("popup_type", popupType.String()).
447+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
448+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
319 } else {449 } else {
320- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support popup lifecycle callbacks (PopupCapable)")450+ // Engine does not support PopupCapable (e.g. CEF OSR where we create
451+ // an independent browser, not a real CEF popup). The WebView is ready
452+ // immediately — fire ready-to-show inline.
453+ log.Info().
454+ Uint64("popup_id", uint64(popupID)).
455+ Str("pane_id", string(paneID)).
456+ Str("popup_type", popupType.String()).
457+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
458+ Msg("popup inserted, immediately ready (no PopupCapable)")
459+ c.handlePopupReadyToShow(ctx, popupID)
321 }460 }
322 461
323- log.Info().
324- Uint64("popup_id", uint64(popupID)).
325- Str("pane_id", string(paneID)).
326- Str("popup_type", popupType.String()).
327- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
328- Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
329-
330 return popupWV462 return popupWV
331 }463 }
332 464
@@ -354,12 +486,19 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
354 Str("popup_type", pending.PopupType.String()).486 Str("popup_type", pending.PopupType.String()).
355 Msg("popup ready to show - making visible")487 Msg("popup ready to show - making visible")
356 488
357- // Make the WebView visible now that it's ready489+ // Make the WebView visible now that it's ready.
358 if pending.WebView != nil {490 if pending.WebView != nil {
359 if pc, ok := pending.WebView.(port.PopupCapable); ok {491 if pc, ok := pending.WebView.(port.PopupCapable); ok {
360 pc.Show()492 pc.Show()
361- } else {493+ }
362- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)")494+ // For engines that create independent browsers (not real popups),
495+ // the WebView has no pending navigation — load the target URI.
496+ if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
497+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
498+ log.Warn().Err(err).
499+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
500+ Msg("failed to load target URI in popup")
501+ }
363 }502 }
364 }503 }
365 504
@@ -389,6 +528,7 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
389 }528 }
390 }529 }
391 c.handlePopupOAuthClose(ctx, popupID)530 c.handlePopupOAuthClose(ctx, popupID)
531+ c.clearReusableNamedPopupByWebViewID(popupID)
392 c.ReleaseWebView(ctx, pending.PaneID)532 c.ReleaseWebView(ctx, pending.PaneID)
393 log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")533 log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
394 return534 return
@@ -407,6 +547,7 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
407 547
408 if paneID == "" {548 if paneID == "" {
409 c.handlePopupOAuthClose(ctx, popupID)549 c.handlePopupOAuthClose(ctx, popupID)
550+ c.clearReusableNamedPopupByWebViewID(popupID)
410 log.Warn().Msg("popup close: could not find pane for webview")551 log.Warn().Msg("popup close: could not find pane for webview")
411 return552 return
412 }553 }
@@ -419,6 +560,7 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
419 }560 }
420 561
421 c.handlePopupOAuthClose(ctx, popupID)562 c.handlePopupOAuthClose(ctx, popupID)
563+ c.clearReusableNamedPopupByWebViewID(popupID)
422 564
423 // Release the WebView (this will clean up tracking)565 // Release the WebView (this will clean up tracking)
424 c.ReleaseWebView(ctx, paneID)566 c.ReleaseWebView(ctx, paneID)
M internal/ui/coordinator/content/popup_test.go +58 -0
@@ -6,9 +6,11 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
13 )15 )
14 16
@@ -123,6 +125,62 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
123 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))125 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
124 }126 }
125 127
128+func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
129+ ctx := context.Background()
130+ parentPaneID := entity.PaneID("parent-pane")
131+ parentWV := mocks.NewMockWebView(t)
132+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
133+
134+ popupWV := mocks.NewMockWebView(t)
135+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Twice()
136+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
137+ popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
138+ popupWV.EXPECT().IsLoading().Return(false).Maybe()
139+ currentURI := ""
140+ popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
141+ popupWV.EXPECT().IsDestroyed().Return(false).Maybe()
142+ popupWV.EXPECT().LoadURI(mock.Anything, "https://accounts.google.com/o/oauth2/v2/auth?first").RunAndReturn(func(context.Context, string) error {
143+ currentURI = "https://accounts.google.com/o/oauth2/v2/auth?first"
144+ return nil
145+ }).Once()
146+ popupWV.EXPECT().LoadURI(mock.Anything, "https://accounts.google.com/o/oauth2/v2/auth?second").RunAndReturn(func(context.Context, string) error {
147+ currentURI = "https://accounts.google.com/o/oauth2/v2/auth?second"
148+ return nil
149+ }).Once()
150+
151+ factory := mocks.NewMockWebViewFactory(t)
152+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
153+
154+ insertCalls := 0
155+ c := &Coordinator{
156+ webViews: make(map[entity.PaneID]port.WebView),
157+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
158+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
159+ }
160+ c.SetPopupConfig(factory, nil, nil)
161+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
162+ insertCalls++
163+ return nil
164+ })
165+
166+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
167+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?first",
168+ FrameName: "g_credential_picker_x",
169+ IsUserGesture: true,
170+ })
171+ require.Same(t, popupWV, first)
172+
173+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
174+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth?second",
175+ FrameName: "g_credential_picker_x",
176+ IsUserGesture: true,
177+ })
178+ require.Same(t, popupWV, second)
179+
180+ assert.Equal(t, 1, insertCalls)
181+ assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth?second", currentURI)
182+}
183+
126 // ---------------------------------------------------------------------------184 // ---------------------------------------------------------------------------
127 // trackOAuthPopup / capturePopupOAuthState185 // trackOAuthPopup / capturePopupOAuthState
128 // ---------------------------------------------------------------------------186 // ---------------------------------------------------------------------------
M internal/ui/coordinator/content/title.go +1 -1
@@ -286,7 +286,7 @@ func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.Pa
286 }286 }
287 287
288 // Check memory and disk cache (no external fetch)288 // Check memory and disk cache (no external fetch)
289- texture := c.faviconAdapter.PreloadFromCache(uri)289+ texture := c.faviconAdapter.PreloadFromCache(ctx, uri)
290 select {290 select {
291 case <-ctx.Done():291 case <-ctx.Done():
292 return292 return
M internal/ui/coordinator/navigation.go +1 -5
@@ -241,15 +241,11 @@ func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
241 241
242 // UpdateHistoryTitle updates the title of a history entry after page load.242 // UpdateHistoryTitle updates the title of a history entry after page load.
243 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {243 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
244- log := logging.FromContext(ctx)
245-
246 if c.navigateUC == nil {244 if c.navigateUC == nil {
247 return245 return
248 }246 }
249 247
250- if err := c.navigateUC.UpdateHistoryTitle(ctx, url, title); err != nil {248+ c.navigateUC.UpdateHistoryTitle(ctx, url, title)
251- log.Warn().Err(err).Str("url", url).Msg("failed to update history title")
252- }
253 }249 }
254 250
255 // RecordHistory records a URL in history on page commit.251 // RecordHistory records a URL in history on page commit.
M internal/ui/coordinator/workspace.go +4 -4
@@ -358,7 +358,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
358 Msg("stack split: determined split type")358 Msg("stack split: determined split type")
359 359
360 // 3. Create new PaneView for the new pane (without WebView - will attach later)360 // 3. Create new PaneView for the new pane (without WebView - will attach later)
361- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)361+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
362 setupPaneViewHover(ctx, newPaneView, wsView)362 setupPaneViewHover(ctx, newPaneView, wsView)
363 363
364 // 4. Wrap the new PaneView in a StackedView364 // 4. Wrap the new PaneView in a StackedView
@@ -534,7 +534,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
534 Msg("determined split type")534 Msg("determined split type")
535 535
536 // 4. Create new PaneView for the new pane536 // 4. Create new PaneView for the new pane
537- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)537+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
538 setupPaneViewHover(ctx, newPaneView, wsView)538 setupPaneViewHover(ctx, newPaneView, wsView)
539 539
540 // 5. Wrap the new PaneView in a StackedView540 // 5. Wrap the new PaneView in a StackedView
@@ -1438,7 +1438,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1438 }1438 }
1439 1439
1440 // Create PaneView for the new pane1440 // Create PaneView for the new pane
1441- newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)1441+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)
1442 setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)1442 setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1443 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)1443 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1444 1444
@@ -2084,7 +2084,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
2084 if wsView == nil {2084 if wsView == nil {
2085 return nil2085 return nil
2086 }2086 }
2087- newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)2087+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, input.PopupPane.ID, nil)
2088 setupPaneViewHover(ctx, newPaneView, wsView)2088 setupPaneViewHover(ctx, newPaneView, wsView)
2089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)2089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
2090 2090
M internal/ui/deps.go +6 -0
@@ -38,6 +38,9 @@ type Dependencies struct {
38 // Engine (replaces individual webkit fields)38 // Engine (replaces individual webkit fields)
39 Engine port.Engine39 Engine port.Engine
40 40
41+ // Handler dependencies built by bootstrap (keybindings, config save).
42+ HandlerDeps port.HandlerDeps
43+
41 // Repositories44 // Repositories
42 HistoryRepo repository.HistoryRepository45 HistoryRepo repository.HistoryRepository
43 FavoriteRepo repository.FavoriteRepository46 FavoriteRepo repository.FavoriteRepository
@@ -114,6 +117,9 @@ func (d *Dependencies) Validate() error {
114 if d.Engine == nil {117 if d.Engine == nil {
115 return ErrMissingDependency("Engine")118 return ErrMissingDependency("Engine")
116 }119 }
120+ if d.HandlerDeps.SaveConfig == nil {
121+ return ErrMissingDependency("HandlerDeps.SaveConfig")
122+ }
117 // Use cases are optional - can be nil if not needed123 // Use cases are optional - can be nil if not needed
118 return nil124 return nil
119 }125 }
M internal/ui/standalone_omnibox.go +1 -2
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/component"10 "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/layout"11 "github.com/bnema/dumber/internal/ui/layout"
12- "github.com/bnema/puregotk/v4/adw"
13 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
14 "github.com/bnema/puregotk/v4/gio"13 "github.com/bnema/puregotk/v4/gio"
15 "github.com/bnema/puregotk/v4/glib"14 "github.com/bnema/puregotk/v4/glib"
@@ -345,7 +344,7 @@ func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRunt
345 return 1344 return 1
346 }345 }
347 346
348- adw.Init()347+ EnsureAdwaitaInitialized()
349 windowCfg := DefaultStandaloneOmniboxWindowConfig()348 windowCfg := DefaultStandaloneOmniboxWindowConfig()
350 appID := windowCfg.Namespace349 appID := windowCfg.Namespace
351 gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())350 gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
M internal/ui/theme/css.go +0 -2
@@ -442,8 +442,6 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
442 color: var(--muted);442 color: var(--muted);
443 margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */443 margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */
444 background-color: transparent;444 background-color: transparent;
445- /* Allow clicks to pass through to entry below */
446- pointer-events: none;
447 }445 }
448 `446 `
449 }447 }
M internal/ui/theme/css_test.go +7 -0
@@ -104,3 +104,10 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
104 assert.Contains(t, css, "font-weight: 500;")104 assert.Contains(t, css, "font-weight: 500;")
105 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")105 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
106 }106 }
107+
108+func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
109+ css := GenerateCSS(DefaultDarkPalette())
110+
111+ assert.NotContains(t, css, "pointer-events:")
112+ assert.NotContains(t, css, "text-align:")
113+}
M internal/ui/theme/webrtc_indicator_css.go +0 -1
@@ -76,7 +76,6 @@ func generateWebRTCPermissionIndicatorCSS(p Palette) string {
76 background-color: alpha(var(--bg), 0.35);76 background-color: alpha(var(--bg), 0.35);
77 background-image: none;77 background-image: none;
78 color: var(--muted);78 color: var(--muted);
79- text-align: left;
80 transition: background-color 150ms ease-in-out,79 transition: background-color 150ms ease-in-out,
81 border-color 150ms ease-in-out,80 border-color 150ms ease-in-out,
82 color 150ms ease-in-out;81 color 150ms ease-in-out;
M webui/src/lib/bridge.ts +6 -4
@@ -18,11 +18,13 @@ interface WebKitBridge {
18 }18 }
19 19
20 /**20 /**
21- * Get the WebKit message handler bridge, if available.21+ * Get the native bridge, if available.
22+ * WebKit exposes `window.webkit.messageHandlers.dumber` while CEF injects
23+ * `window.dumber.postMessage`.
22 */24 */
23 export function getWebKitBridge(): WebKitBridge | null {25 export function getWebKitBridge(): WebKitBridge | null {
24 // eslint-disable-next-line @typescript-eslint/no-explicit-any26 // eslint-disable-next-line @typescript-eslint/no-explicit-any
25- const bridge = (window as any).webkit?.messageHandlers?.dumber;27+ const bridge = (window as any).webkit?.messageHandlers?.dumber ?? (window as any).dumber;
26 return bridge && typeof bridge.postMessage === "function" ? bridge : null;28 return bridge && typeof bridge.postMessage === "function" ? bridge : null;
27 }29 }
28 30
@@ -50,7 +52,7 @@ export function toPlainObject<T>(obj: T): T {
50 }52 }
51 53
52 /**54 /**
53- * Post a message to the Go backend via WebKit bridge.55+ * Post a message to the Go backend via the native bridge.
54 *56 *
55 * Automatically:57 * Automatically:
56 * - Converts Svelte 5 Proxy objects to plain objects58 * - Converts Svelte 5 Proxy objects to plain objects
@@ -62,7 +64,7 @@ export function toPlainObject<T>(obj: T): T {
62 export function postMessage(msg: BridgeMessage): void {64 export function postMessage(msg: BridgeMessage): void {
63 const bridge = getWebKitBridge();65 const bridge = getWebKitBridge();
64 if (!bridge) {66 if (!bridge) {
65- throw new Error("WebKit bridge not available");67+ throw new Error("Dumber bridge not available");
66 }68 }
67 69
68 // Ensure webviewId is set70 // Ensure webviewId is set
M webui/src/pages/ConfigPage.svelte +81 -7
@@ -1,6 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3- import { ModeWatcher } from "mode-watcher";3+ import { ModeWatcher, resetMode, setMode } from "mode-watcher";
4 import ConfigShell from "./config/ConfigShell.svelte";4 import ConfigShell from "./config/ConfigShell.svelte";
5 import ShortcutsTable from "./config/ShortcutsTable.svelte";5 import ShortcutsTable from "./config/ShortcutsTable.svelte";
6 import KeybindingsTab from "./config/KeybindingsTab.svelte";6 import KeybindingsTab from "./config/KeybindingsTab.svelte";
@@ -83,6 +83,7 @@
83 default_ui_scale: number;83 default_ui_scale: number;
84 default_search_engine: string;84 default_search_engine: string;
85 search_shortcuts: Record<string, SearchShortcut>;85 search_shortcuts: Record<string, SearchShortcut>;
86+ engine_type: string;
86 };87 };
87 88
88 const PERFORMANCE_PROFILES = [89 const PERFORMANCE_PROFILES = [
@@ -117,6 +118,49 @@
117 let themeEvents = $state(0);118 let themeEvents = $state(0);
118 let activeTab = $state("appearance");119 let activeTab = $state("appearance");
119 120
121+ function getResolvedDarkPreference(fallback?: boolean): boolean {
122+ if (typeof fallback === "boolean") {
123+ return fallback;
124+ }
125+ if (typeof window.__dumber_cef_prefers_dark === "boolean") {
126+ return window.__dumber_cef_prefers_dark;
127+ }
128+ if (typeof window.__dumber_gtk_prefers_dark === "boolean") {
129+ return window.__dumber_gtk_prefers_dark;
130+ }
131+ return document.documentElement.classList.contains("dark");
132+ }
133+
134+ function applyResolvedTheme(prefersDark: boolean): void {
135+ const root = document.documentElement;
136+ root.classList.toggle("dark", prefersDark);
137+ root.classList.toggle("light", !prefersDark);
138+ root.style.colorScheme = prefersDark ? "dark" : "light";
139+ window.__dumber_cef_prefers_dark = prefersDark;
140+ window.__dumber_gtk_prefers_dark = prefersDark;
141+ }
142+
143+ function syncModeWatcherPreference(colorScheme: string | undefined, prefersDark?: boolean): void {
144+ switch (colorScheme) {
145+ case "prefer-dark":
146+ setMode("dark");
147+ applyResolvedTheme(true);
148+ break;
149+ case "prefer-light":
150+ setMode("light");
151+ applyResolvedTheme(false);
152+ break;
153+ default:
154+ resetMode();
155+ applyResolvedTheme(getResolvedDarkPreference(prefersDark));
156+ break;
157+ }
158+ }
159+
160+ function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161+ return cfg != null && cfg.engine_type !== "cef";
162+ }
163+
120 // Load config from API164 // Load config from API
121 async function loadConfig() {165 async function loadConfig() {
122 loading = true;166 loading = true;
@@ -125,6 +169,10 @@
125 const response = await fetch("/api/config");169 const response = await fetch("/api/config");
126 if (!response.ok) throw new Error("Failed to fetch config");170 if (!response.ok) throw new Error("Failed to fetch config");
127 config = (await response.json()) as ConfigDTO;171 config = (await response.json()) as ConfigDTO;
172+ syncModeWatcherPreference(config.appearance.color_scheme);
173+ if (!isPerformanceSupported(config) && activeTab === "performance") {
174+ activeTab = "appearance";
175+ }
128 } catch (e: unknown) {176 } catch (e: unknown) {
129 loadError = getErrorMessage(e);177 loadError = getErrorMessage(e);
130 console.error("[config] load failed", e);178 console.error("[config] load failed", e);
@@ -146,6 +194,10 @@
146 const response = await fetch("/api/config/default");194 const response = await fetch("/api/config/default");
147 if (!response.ok) throw new Error("Failed to fetch default config");195 if (!response.ok) throw new Error("Failed to fetch default config");
148 config = (await response.json()) as ConfigDTO;196 config = (await response.json()) as ConfigDTO;
197+ syncModeWatcherPreference(config.appearance.color_scheme);
198+ if (!isPerformanceSupported(config) && activeTab === "performance") {
199+ activeTab = "appearance";
200+ }
149 } catch (e: unknown) {201 } catch (e: unknown) {
150 console.error("[config] reset defaults failed", e);202 console.error("[config] reset defaults failed", e);
151 }203 }
@@ -153,7 +205,7 @@
153 }205 }
154 206
155 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {207 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
156- const bridge = window.webkit?.messageHandlers?.dumber;208+ const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
157 if (bridge && typeof bridge.postMessage === "function") {209 if (bridge && typeof bridge.postMessage === "function") {
158 return bridge;210 return bridge;
159 }211 }
@@ -180,7 +232,7 @@
180 });232 });
181 233
182 if (!bridge) {234 if (!bridge) {
183- throw new Error("WebKit bridge not available (not running inside Dumber)");235+ throw new Error("Dumber bridge not available (not running inside Dumber)");
184 }236 }
185 if (!config) {237 if (!config) {
186 throw new Error("Config not loaded");238 throw new Error("Config not loaded");
@@ -208,10 +260,12 @@
208 saving = false;260 saving = false;
209 261
210 // Show restart warning if performance tab was active262 // Show restart warning if performance tab was active
211- if (activeTab === "performance") {263+ if (activeTab === "performance" && isPerformanceSupported(config)) {
212 showRestartWarning = true;264 showRestartWarning = true;
213 }265 }
214 266
267+ syncModeWatcherPreference(config?.appearance.color_scheme);
268+
215 // Refresh config from backend (in case watcher normalized values)269 // Refresh config from backend (in case watcher normalized values)
216 reloadConfig();270 reloadConfig();
217 271
@@ -253,12 +307,14 @@
253 onMount(() => {307 onMount(() => {
254 console.debug("[config] mount", {308 console.debug("[config] mount", {
255 webviewId: window.__dumber_webview_id,309 webviewId: window.__dumber_webview_id,
256- hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),310+ hasBridge: Boolean(getWebKitBridge()),
257 });311 });
258 312
259 const onThemeChanged = (e: Event) => {313 const onThemeChanged = (e: Event) => {
260 themeEvents += 1;314 themeEvents += 1;
261- console.debug("[config] theme changed", (e as CustomEvent).detail);315+ const detail = (e as CustomEvent<{ prefersDark?: boolean }>).detail;
316+ syncModeWatcherPreference(config?.appearance.color_scheme, detail?.prefersDark);
317+ console.debug("[config] theme changed", detail);
262 };318 };
263 window.addEventListener("dumber:theme-changed", onThemeChanged);319 window.addEventListener("dumber:theme-changed", onThemeChanged);
264 320
@@ -305,10 +361,22 @@
305 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>361 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
306 <Tabs.Trigger value="search">Search</Tabs.Trigger>362 <Tabs.Trigger value="search">Search</Tabs.Trigger>
307 <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>363 <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
308- <Tabs.Trigger value="performance">Performance</Tabs.Trigger>364+ <Tabs.Trigger
365+ value="performance"
366+ disabled={!isPerformanceSupported(config)}
367+ title={!isPerformanceSupported(config) ? "Performance tuning is only available with the WebKit engine" : undefined}
368+ >
369+ Performance
370+ </Tabs.Trigger>
309 </Tabs.List>371 </Tabs.List>
310 </div>372 </div>
311 373
374+ {#if !isPerformanceSupported(config)}
375+ <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
376+ Performance settings are disabled while using the CEF engine because these options are specific to WebKitGTK.
377+ </div>
378+ {/if}
379+
312 <Tabs.Content value="appearance">380 <Tabs.Content value="appearance">
313 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">381 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
314 <Card.Header>382 <Card.Header>
@@ -449,6 +517,11 @@
449 </Card.Description>517 </Card.Description>
450 </Card.Header>518 </Card.Header>
451 <Card.Content class="space-y-6">519 <Card.Content class="space-y-6">
520+ {#if !isPerformanceSupported(config)}
521+ <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
522+ This section is unavailable under the CEF engine.
523+ </div>
524+ {:else}
452 <!-- Detected Hardware Card -->525 <!-- Detected Hardware Card -->
453 {#if config.performance?.hardware}526 {#if config.performance?.hardware}
454 {@const hw = config.performance.hardware}527 {@const hw = config.performance.hardware}
@@ -614,6 +687,7 @@
614 </div>687 </div>
615 </div>688 </div>
616 {/if}689 {/if}
690+ {/if}
617 </Card.Content>691 </Card.Content>
618 </Card.Root>692 </Card.Root>
619 </Tabs.Content>693 </Tabs.Content>
M webui/src/pages/config/KeybindingsTab.svelte +2 -2
@@ -54,7 +54,7 @@
54 error = null;54 error = null;
55 55
56 if (!getWebKitBridge()) {56 if (!getWebKitBridge()) {
57- error = "WebKit bridge not available";57+ error = "Dumber bridge not available";
58 loading = false;58 loading = false;
59 return;59 return;
60 }60 }
@@ -93,7 +93,7 @@
93 if (!editingBinding) return;93 if (!editingBinding) return;
94 94
95 if (!getWebKitBridge()) {95 if (!getWebKitBridge()) {
96- error = "WebKit bridge not available";96+ error = "Dumber bridge not available";
97 return;97 return;
98 }98 }
99 99
M webui/src/pages/homepage/messaging.ts +4 -4
@@ -1,6 +1,6 @@
1 // ═══════════════════════════════════════════════════════════════════════════════1 // ═══════════════════════════════════════════════════════════════════════════════
2-// Homepage WebKit Bridge2+// Homepage bridge
3-// Communication layer between frontend and Go backend via WebKit message handlers3+// Communication layer between frontend and Go backend via WebKit or CEF bridge.
4 // ═══════════════════════════════════════════════════════════════════════════════4 // ═══════════════════════════════════════════════════════════════════════════════
5 5
6 import type {6 import type {
@@ -43,7 +43,7 @@ let callbacksInitialized = false;
43 // ═══════════════════════════════════════════════════════════════════════════════43 // ═══════════════════════════════════════════════════════════════════════════════
44 44
45 function getWebKitBridge(): DumberBridge | null {45 function getWebKitBridge(): DumberBridge | null {
46- const bridge = window.webkit?.messageHandlers?.dumber;46+ const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
47 if (bridge && typeof bridge.postMessage === 'function') {47 if (bridge && typeof bridge.postMessage === 'function') {
48 return bridge;48 return bridge;
49 }49 }
@@ -178,7 +178,7 @@ async function sendMessage<T = unknown>(
178 178
179 const bridge = getWebKitBridge();179 const bridge = getWebKitBridge();
180 if (!bridge) {180 if (!bridge) {
181- throw new Error('WebKit message handler not available');181+ throw new Error('Dumber bridge not available');
182 }182 }
183 183
184 const requestId = generateRequestId(type);184 const requestId = generateRequestId(type);
M webui/src/types/dumber.d.ts +6 -0
@@ -77,6 +77,8 @@ declare global {
77 __dumber_webview_id?: number;77 __dumber_webview_id?: number;
78 /** WebKit message handlers for native bridge */78 /** WebKit message handlers for native bridge */
79 webkit?: DumberWebKit;79 webkit?: DumberWebKit;
80+ /** CEF bridge shim injected on internal pages */
81+ dumber?: DumberBridge;
80 82
81 // ─────────────────────────────────────────────────────────────────────────83 // ─────────────────────────────────────────────────────────────────────────
82 // Config Page Callbacks84 // Config Page Callbacks
@@ -139,6 +141,10 @@ declare global {
139 __dumber_color_scheme_manager?: ColorSchemeManager;141 __dumber_color_scheme_manager?: ColorSchemeManager;
140 /** Legacy theme setter */142 /** Legacy theme setter */
141 __dumber_setTheme?: (theme: ThemeMode) => void;143 __dumber_setTheme?: (theme: ThemeMode) => void;
144+ /** Resolved prefers-dark state injected by the backend */
145+ __dumber_cef_prefers_dark?: boolean;
146+ /** Resolved prefers-dark state injected by the backend */
147+ __dumber_gtk_prefers_dark?: boolean;
142 }148 }
143 149
144 interface Document {150 interface Document {
M webui/vite-plugin-pages.ts +46 -1
@@ -15,6 +15,49 @@ interface PageConfig {
15 filename?: string;15 filename?: string;
16 }16 }
17 17
18+// Bootstrap a unified window.dumber bridge before the page bundle runs.
19+// On WebKit internal pages we forward to the native message handler.
20+// On CEF internal pages we fall back to the fetch-based /api/message bridge.
21+function getBridgeBootstrap(): string {
22+ return `<script>
23+ (function() {
24+ if (window.dumber && typeof window.dumber.postMessage === "function") {
25+ return;
26+ }
27+
28+ window.dumber = {
29+ postMessage: async function(msg) {
30+ const nativeBridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber;
31+ if (nativeBridge && typeof nativeBridge.postMessage === "function") {
32+ return nativeBridge.postMessage(msg);
33+ }
34+
35+ try {
36+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
37+ const response = await fetch("/api/message", {
38+ method: "POST",
39+ headers: {
40+ "Content-Type": "application/json",
41+ "X-Dumber-Body": encoded
42+ }
43+ });
44+ const payload = await response.json();
45+ if (payload && payload._callback && typeof window[payload._callback] === "function") {
46+ window[payload._callback](payload.data);
47+ }
48+ return payload;
49+ } catch (err) {
50+ console.error("[dumber:bridge] fetch bridge error:", err);
51+ return { error: err instanceof Error ? err.message : String(err) };
52+ }
53+ }
54+ };
55+
56+ window.dispatchEvent(new CustomEvent("dumber:bridge-ready"));
57+ })();
58+ </script>`;
59+}
60+
18 // Load the favicon SVG from assets61 // Load the favicon SVG from assets
19 function getFaviconSVG(): string {62 function getFaviconSVG(): string {
20 const logoPath = resolve(__dirname, "..", "assets", "logo.svg");63 const logoPath = resolve(__dirname, "..", "assets", "logo.svg");
@@ -26,6 +69,7 @@ function generatePageHTML(config: PageConfig): string {
26 const cssLink = config.css69 const cssLink = config.css
27 ? `<link rel="stylesheet" href="./${config.css}">`70 ? `<link rel="stylesheet" href="./${config.css}">`
28 : "";71 : "";
72+ const bridgeBootstrap = getBridgeBootstrap();
29 73
30 return `<!DOCTYPE html>74 return `<!DOCTYPE html>
31 <html lang="en">75 <html lang="en">
@@ -33,7 +77,8 @@ function generatePageHTML(config: PageConfig): string {
33 <meta charset="UTF-8">77 <meta charset="UTF-8">
34 <meta name="viewport" content="width=device-width, initial-scale=1.0">78 <meta name="viewport" content="width=device-width, initial-scale=1.0">
35 <title>${config.title}</title>79 <title>${config.title}</title>
36- <link rel="icon" type="image/x-icon" href="dumb://homepage/favicon.ico">80+ <link rel="icon" type="image/png" href="/favicon.png">
81+ ${bridgeBootstrap}
37 ${cssLink}82 ${cssLink}
38 </head>83 </head>
39 <body>84 <body>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close