Merge branch 'feat/cef-engine-adapter'
154 files · +15560 -2107
M .gitignore +3 -0
| @@ -8,6 +8,7 @@ dist/ | |||||
| 8 | 8 | ||||
| 9 | # Binaries for programs and plugins | 9 | # Binaries for programs and plugins | ||
| 10 | /dumber | 10 | /dumber | ||
| 11 | + | /cef-helper | |||
| 11 | dumber.exe | 12 | 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 | *.tmp | 43 | *.tmp | ||
| 43 | *.temp | 44 | *.temp | ||
| 44 | *.log | 45 | *.log | ||
| 46 | + | __debug_bin* | |||
| 45 | 47 | ||||
| 46 | # Database files | 48 | # Database files | ||
| 47 | *.db | 49 | *.db | ||
| @@ -116,3 +118,4 @@ aur/*/logo-512.png | |||||
| 116 | 118 | ||||
| 117 | # Planning/runbook notes stored outside product docs | 119 | # Planning/runbook notes stored outside product docs | ||
| 118 | docs/reference/stability-performance-multi-agent-runbook-*.md | 120 | 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 | - gosec | 148 | - 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 | - gosec | 156 | - gosec | ||
| @@ -171,6 +176,11 @@ linters: | |||||
| 171 | text: "unused-receiver" | 176 | text: "unused-receiver" | ||
| 172 | linters: | 177 | linters: | ||
| 173 | - revive | 178 | - 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: 20m | 186 | 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 flags | 24 | # 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 target | 35 | # Default target | ||
| 28 | help: ## Show this help message | 36 | 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 pages | 48 | 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 targets | 60 | # 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 command | 104 | // Run GUI mode for browse command | ||
| 94 | if mode == launchModeBrowse { | 105 | if mode == launchModeBrowse { | ||
| 95 | initialURL = browseURL | 106 | 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.AutoRestore | 155 | 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 1 | 210 | 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, cleanup | 339 | 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 := <-sigCh | 547 | 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.FilterManager | 687 | 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 uiDeps | 767 | + | 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.1 | 3 | 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.1 | 9 | github.com/bnema/purego-sqlite v0.1.1 | ||
| 7 | github.com/bnema/puregotk v0.3.0 | 10 | github.com/bnema/puregotk v0.3.0 | ||
| 8 | github.com/bnema/puregotk-webkit v0.0.16 | 11 | github.com/bnema/puregotk-webkit v0.0.16 | ||
| @@ -12,6 +15,7 @@ require ( | |||||
| 12 | github.com/ebitengine/purego v0.10.0 | 15 | github.com/ebitengine/purego v0.10.0 | ||
| 13 | github.com/fsnotify/fsnotify v1.9.0 | 16 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 14 | github.com/godbus/dbus/v5 v5.2.2 | 17 | 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.0 | 19 | github.com/pelletier/go-toml/v2 v2.3.0 | ||
| 16 | github.com/pressly/goose/v3 v3.27.0 | 20 | github.com/pressly/goose/v3 v3.27.0 | ||
| 17 | github.com/rs/zerolog v1.34.0 | 21 | github.com/rs/zerolog v1.34.0 | ||
| @@ -23,8 +27,6 @@ require ( | |||||
| 23 | gopkg.in/yaml.v3 v3.0.1 | 27 | 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 // indirect | 31 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| @@ -66,4 +68,7 @@ require ( | |||||
| 66 | golang.org/x/sync v0.20.0 // indirect | 68 | golang.org/x/sync v0.20.0 // indirect | ||
| 67 | golang.org/x/text v0.35.0 // indirect | 69 | golang.org/x/text v0.35.0 // indirect | ||
| 68 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 70 | 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 port | 4 | 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 string | 12 | type CookiePolicy string | ||
| @@ -157,6 +160,37 @@ type EngineSettingsUpdate struct { | |||||
| 157 | Raw any //nolint:iface // intentional: see type comment above | 160 | 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 HomepageHistory | 196 | HistoryUC HomepageHistory | ||
| @@ -164,6 +198,7 @@ type HandlerDependencies struct { | |||||
| 164 | Clipboard Clipboard | 198 | Clipboard Clipboard | ||
| 165 | AutoCopyConfig AutoCopyConfig | 199 | 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.) uniformly | 215 | // 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) string | 9 | DiskPathPNG(domain string) string | ||
| 10 | HasPNGOnDisk(domain string) bool | 10 | 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() bool | 31 | 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 _c | 3396 | 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.TestingT | 3402 | 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 mock | 3410 | return mock | ||
| 3410 | } | 3411 | } | ||
| 3411 | 3412 | ||||
| 3412 | - | // MockSchemeHandler is an autogenerated mock type for the SchemeHandler type | 3413 | + | // MockWebUIMessageHandler is an autogenerated mock type for the WebUIMessageHandler type |
| 3413 | - | type MockSchemeHandler struct { | 3414 | + | type MockWebUIMessageHandler struct { |
| 3414 | mock.Mock | 3415 | mock.Mock | ||
| 3415 | } | 3416 | } | ||
| 3416 | 3417 | ||||
| 3417 | - | type MockSchemeHandler_Expecter struct { | 3418 | + | type MockWebUIMessageHandler_Expecter struct { |
| 3418 | mock *mock.Mock | 3419 | 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 MockSchemeHandler | 3426 | + | // 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 | - | return | 3429 | + | |
| 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.Call | 3456 | *mock.Call | ||
| 3434 | } | 3457 | } | ||
| 3435 | 3458 | ||||
| 3436 | - | // RegisterScheme is a helper method to define mock.On call | 3459 | + | // Handle is a helper method to define mock.On call |
| 3437 | - | // - scheme string | 3460 | + | // - 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 string | 3469 | + | 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 _c | 3487 | 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 _c | 3492 | 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 _c | 3497 | 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.TestingT | 3503 | 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 mock | 3511 | return mock | ||
| 3483 | } | 3512 | } | ||
| 3484 | 3513 | ||||
| 3485 | - | // MockMessageRouter is an autogenerated mock type for the MessageRouter type | 3514 | + | // MockWebUIHandlerRouter is an autogenerated mock type for the WebUIHandlerRouter type |
| 3486 | - | type MockMessageRouter struct { | 3515 | + | type MockWebUIHandlerRouter struct { |
| 3487 | mock.Mock | 3516 | mock.Mock | ||
| 3488 | } | 3517 | } | ||
| 3489 | 3518 | ||||
| 3490 | - | type MockMessageRouter_Expecter struct { | 3519 | + | type MockWebUIHandlerRouter_Expecter struct { |
| 3491 | mock *mock.Mock | 3520 | 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 MockMessageRouter | 3527 | + | // 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 error | 3535 | 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 r0 | 3541 | 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.Call | 3546 | *mock.Call | ||
| 3518 | } | 3547 | } | ||
| 3519 | 3548 | ||||
| 3520 | - | // PostMessage is a helper method to define mock.On call | 3549 | + | // RegisterHandler is a helper method to define mock.On call |
| 3521 | - | // - webviewID port.WebViewID | 3550 | + | // - msgType string |
| 3522 | - | // - message string | 3551 | + | // - 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.WebViewID | 3618 | + | 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 string | 3622 | 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 _c | 3646 | 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 _c | 3651 | 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 _c | 3656 | return _c | ||
| 3553 | } | 3657 | } | ||
| 3554 | 3658 | ||||
| 3555 | - | // RegisterHandler provides a mock function for the type MockMessageRouter | 3659 | + | // 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 | return | 3689 | 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.Call | 3694 | *mock.Call | ||
| 3564 | } | 3695 | } | ||
| 3565 | 3696 | ||||
| 3566 | - | // RegisterHandler is a helper method to define mock.On call | 3697 | + | // RegisterScheme is a helper method to define mock.On call |
| 3567 | - | // - name string | 3698 | + | // - 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 string | 3706 | 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 _c | 3719 | 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 _c | 3724 | 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 _c | 3729 | 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 MockFaviconService | 4427 | // 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 []byte | 4435 | var r0 []byte | ||
| 4305 | var r1 bool | 4436 | 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 call | 4460 | // GetCached is a helper method to define mock.On call | ||
| 4461 | + | // - ctx context.Context | |||
| 4330 | // - domain string | 4462 | // - 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 string | 4469 | + | 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 _c | 4482 | return _c | ||
| @@ -4350,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF | |||||
| 4350 | return _c | 4487 | 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 _c | 4492 | 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.SessionStateRepository | 20 | stateRepo repository.SessionStateRepository | ||
| 21 | sessionRepo repository.SessionRepository | 21 | 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 ErrSessionNotFound | 57 | 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 ErrCannotDeleteActiveSession | 62 | 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.SessionRepository | 22 | sessionRepo repository.SessionRepository | ||
| 23 | stateRepo repository.SessionStateRepository | 23 | 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 | continue | 72 | continue | ||
| 76 | } | 73 | } | ||
| 77 | 74 | ||||
| 78 | - | // Check if session is actively running | 75 | + | // 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 file | 76 | + | 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_test | 1 | 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 snapshot | 141 | 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 file | 253 | + | // 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 crash | 260 | + | 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 match | 337 | // 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 snapshot | 384 | 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.SessionRepository | 14 | sessionRepo repository.SessionRepository | ||
| 15 | stateRepo repository.SessionStateRepository | 15 | 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}, nil | 91 | 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 lists | 96 | // 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 fails | 173 | // 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 string | 34 | url string | ||
| 35 | + | title string // non-empty for title-update-only records | |||
| 35 | visits int | 36 | 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 | return | 247 | 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.visits | 281 | + | processRecord(record) |
| 247 | default: | 282 | default: | ||
| 248 | return | 283 | 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.visits | 291 | + | 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 here | 365 | + | 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 = title | 368 | 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 nil | 373 | + | 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 nil | 391 | + | // 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 * 1024 | 24 | 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 "", err | 69 | 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, nil | 78 | + | 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, nil | 79 | 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 = startupPID | 103 | + | 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 = logPath | 104 | 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 "", err | 112 | 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 "", err | 116 | 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 "", err | 121 | return "", err | ||
| 146 | } | 122 | } | ||
| 147 | 123 | ||||
| 148 | return jsonPath, nil | 124 | 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 bootstrap | 3 | 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]any | 107 | 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 + last | 119 | 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.Rlimit | 17 | 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 d | 21 | 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.Config | 33 | cfg := input.Config | ||
| 31 | - | engineType := cfg.Engine.Type | 34 | + | 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 error | 154 | 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 = 200 | 26 | recentSessionsLimit = 200 | ||
| 26 | - | lockDirPerm = 0o755 | 27 | + | dirPerm = 0o755 |
| 27 | - | lockFilePerm = 0o600 | 28 | + | filePerm = 0o644 |
| 28 | - | markerFilePerm = 0o644 | 29 | + | 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.Session | 34 | Session *entity.Session | ||
| 33 | Logger zerolog.Logger | 35 | Logger zerolog.Logger | ||
| 34 | LogCleanup func() | 36 | LogCleanup func() | ||
| 37 | + | LogPath string | |||
| 35 | 38 | ||||
| 36 | - | reportsMu sync.RWMutex | 39 | + | reportsMu sync.RWMutex |
| 37 | - | unexpectedCloseReportPath []string | 40 | + | crashReportPaths []string |
| 38 | 41 | ||||
| 39 | Persist func(context.Context) error | 42 | Persist func(context.Context) error | ||
| 40 | End func(context.Context) error | 43 | 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 | return | 49 | 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 nil | 59 | 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.LogDir | 80 | + | 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 = dir | 83 | + | 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 calls | 131 | + | var persistErr error |
| 123 | - | // rather than retrying. This prevents duplicate session records and ensures consistent | 132 | + | |
| 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 persistErr | 161 | 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 := lockFile | 169 | + | if logDir != "" { |
| 176 | - | lp := lockPath | 170 | + | 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 endErr | 172 | return endErr | ||
| 191 | } | 173 | } | ||
| 192 | 174 | ||||
| 193 | - | browserSession.End = endFn | 175 | + | bs.End = endFn |
| 194 | 176 | ||||
| 195 | if deferPersist { | 177 | if deferPersist { | ||
| 196 | - | browserSession.Persist = persistFn | 178 | + | 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, nil | 188 | + | 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, nil | 195 | + | 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, "", err | 216 | + | 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, "", err | 218 | + | 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 nil | 225 | 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, nil | 230 | + | 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 | - | continue | 237 | + | 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, nil | 241 | + | // 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, statErr | 251 | + | 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, statErr | 256 | + | Int("crashed_pid", pid). |
| 495 | - | } else if exists { | 257 | + | Str("session_id", lastSessionID). |
| 496 | - | return false, nil | 258 | + | Msg("previous session crashed (stale PID file)") |
| 497 | } | 259 | } | ||
| 498 | 260 | ||||
| 499 | - | return true, nil | 261 | + | 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, openErr | 266 | + | 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, nil | 277 | + | if err != nil || len(matches) == 0 { |
| 278 | + | return "" | |||
| 543 | } | 279 | } | ||
| 544 | - | if os.IsNotExist(err) { | 280 | + | var newest string |
| 545 | - | return false, nil | 281 | + | 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, err | 292 | + | 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 pruning | 308 | + | 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 limits | 318 | + | // 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 | - | return | 345 | + | } 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 := 0 | 349 | + | // 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 cases | 113 | // 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) error | 18 | + | 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) string | 21 | 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 defaults | 67 | // 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 startup | 69 | 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 = 0 | 79 | defaultSkiaCPUPaintingThreads = 0 | ||
| @@ -151,6 +158,11 @@ func DefaultConfig() *Config { | |||||
| 151 | // third-party cookie isolation more intelligently. Using Always + ITP | 158 | // 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 ~/Downloads | 318 | 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 config | 1 | 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 limit | 79 | 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 the | 114 | // 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.WebKit | 581 | 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 config | 1 | 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 defaults | 36 | // 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 values | 15 | // validateConfig performs comprehensive validation of configuration values | ||
| 14 | func validateConfig(config *Config) error { | 16 | func validateConfig(config *Config) error { | ||
| 15 | var validationErrors []string | 17 | 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 them | 39 | // 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 validationErrors | 327 | 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 []string | 351 | 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 []string | 465 | 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 name | 473 | // 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 config | 1 | 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 debugging | 70 | // Print first 50 lines for debugging | ||
M internal/infrastructure/favicon/cache.go +18 -2
| @@ -1,11 +1,13 @@ | |||||
| 1 | package favicon | 1 | 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, false | 58 | return nil, false | ||
| 57 | } | 59 | } | ||
| 60 | + | log := logging.FromContext(ctx) | |||
| 58 | 61 | ||||
| 59 | // Check memory cache first | 62 | // 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, true | 71 | 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] = data | 79 | 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, true | 85 | return data, true | ||
| 75 | } | 86 | } | ||
| 76 | 87 | ||||
| 88 | + | log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss") | |||
| 77 | return nil, false | 89 | 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 | return | 96 | 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 cache | 103 | // 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, nil | 38 | 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 logo | 43 | // 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, nil | 46 | 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, nil | 55 | return data, nil | ||
| 49 | } | 56 | } | ||
| 50 | 57 | ||||
| 51 | // Fetch from external API | 58 | // 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, err | 62 | return nil, err | ||
| 55 | } | 63 | } | ||
| 56 | 64 | ||||
| 57 | // Store in cache if we got data | 65 | // 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, nil | 74 | 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 nil | 102 | 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 | return | 134 | 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 | return | 142 | return | ||
| 122 | } | 143 | } | ||
| 123 | 144 | ||||
| 124 | - | log := logging.FromContext(ctx) | |||
| 125 | - | ||||
| 126 | // Fetch and store | 145 | // 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 nil | 175 | 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 nil | 189 | 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 | return | 169 | 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 download | 51 | Return(nil, nil) // nil filter triggers fallback to download | ||
| 52 | 52 | ||||
| 53 | // Fallback: download will be triggered since Load returned nil filter | 53 | // 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 triggered | 146 | // 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 fallback | 465 | 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 enabled | 38 | // 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 setShortcutRequest | 54 | 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 getByShortcutRequest | 83 | 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 setFolderRequest | 114 | 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 createFolderRequest | 54 | 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 deleteFolderRequest | 92 | 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 updateFolderRequest | 124 | 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 timelineRequest | 35 | 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 searchRequest | 67 | 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 deleteEntryRequest | 106 | 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 deleteRangeRequest | 135 | 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 domainStatsRequest | 231 | 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 deleteDomainRequest | 261 | 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 world | 25 | const worldName = "" // main world | ||
| 27 | 26 | ||||
| 28 | - | handlers := make(map[string]webkit.MessageHandler) | 27 | + | handlers := make(map[string]port.WebUIMessageHandler) |
| 29 | 28 | ||||
| 30 | // History handlers | 29 | // 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 createTagRequest | 54 | 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 deleteTagRequest | 89 | 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 updateTagRequest | 121 | 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 tagAssignRequest | 169 | 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.ResetKeybindingRequest | 66 | 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 keybindings | 96 | // 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 err | 104 | 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 err | 115 | 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 err | 126 | 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 err | 137 | 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 receive | 14 | // 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.AccentKeyHandler | 18 | 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 err | 29 | return err | ||
| 42 | } | 30 | } | ||
| 43 | } | 31 | } | ||
| 44 | 32 | ||||
| 45 | // Configuration handlers | 33 | // 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 err | 36 | 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 err | 51 | 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 err | 57 | 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, nil | 88 | return nil, nil | ||
| 93 | }, | 89 | }, | ||
| @@ -95,8 +91,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han | |||||
| 95 | return err | 91 | 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, nil | 107 | 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 *DumbSchemeHandler | 24 | schemeHandler *DumbSchemeHandler | ||
| 24 | schemePath string | 25 | schemePath string | ||
| 25 | logger zerolog.Logger | 26 | 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, nil | 120 | 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 only | 250 | // 3. Inject dark mode handler for internal pages only | ||
| 335 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 251 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | ||
| 336 | - | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | 252 | + | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") |
| 337 | addScript( | 253 | addScript( | ||
| 338 | webkit.NewUserScript( | 254 | webkit.NewUserScript( | ||
| 339 | internalDarkModeScript, | 255 | internalDarkModeScript, | ||
| @@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 347 | 263 | ||||
| 348 | // 4. Inject theme CSS for internal pages (dumb://* only) | 264 | // 4. Inject theme CSS for internal pages (dumb://* only) | ||
| 349 | if ci.themeCSSVars != "" { | 265 | if ci.themeCSSVars != "" { | ||
| 350 | - | // Escape for JS string literal | 266 | + | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) |
| 351 | - | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |||
| 352 | - | escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'") | |||
| 353 | - | escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n") | |||
| 354 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | 267 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | ||
| 355 | addScript( | 268 | addScript( | ||
| 356 | webkit.NewUserScript( | 269 | webkit.NewUserScript( | ||
M internal/infrastructure/webkit/messaging.go +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 MessageHandler | 28 | handler MessageHandler | ||
| @@ -40,6 +31,9 @@ type handlerEntry struct { | |||||
| 40 | world string | 31 | 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]handlerEntry | 39 | 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.Context | 73 | 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 = &hwInfo | 171 | hw = &hwInfo | ||
| 347 | } | 172 | } | ||
| 348 | 173 | ||||
| 349 | - | // Resolve performance profile with hardware info | 174 | + | 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 nil | 299 | 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 logging | 1 | 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, err | 107 | 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 configured | 115 | // 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, nil | 126 | 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 variables | 174 | // 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 string | 30 | internalDomain string | ||
| 29 | normalizedIconSize int | 31 | normalizedIconSize int | ||
| 30 | getLogoBytes func() []byte | 32 | 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 logo | 114 | // 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 | return | 117 | 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 first | 126 | // 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 texture | 135 | // 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 available | 151 | // 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 | return | 156 | return | ||
| 170 | } | 157 | } | ||
| 171 | 158 | ||||
| 172 | // No WebKit DB, fetch via service directly | 159 | // 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 | return | 244 | 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 nil | 348 | 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 cache | 356 | // 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 texture | 359 | 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 texture | 371 | return texture | ||
| 291 | } | 372 | } | ||
| 292 | } | 373 | } | ||
| 293 | 374 | ||||
| 375 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss") | |||
| 294 | return nil | 376 | 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 nil | 489 | return nil | ||
| 408 | } | 490 | } | ||
| @@ -414,13 +496,45 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||||
| 414 | 496 | ||||
| 415 | // Create texture from logo bytes | 497 | // 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 texture | 503 | 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.GApplicationNonUniqueValue | 50 | 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 *Dependencies | 64 | 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, err | 209 | 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 err | 387 | return err | ||
| 377 | } | 388 | } | ||
| 378 | a.mainWindow = mainWindow | 389 | 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 presenter | 397 | // 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.0 | 399 | 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's | 2579 | // regular workspace panes. This allows the content coordinator's | ||
| 2565 | // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find | 2580 | // 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.WidgetFactory | 23 | factory layout.WidgetFactory | ||
| 22 | overlay layout.OverlayWidget | 24 | overlay layout.OverlayWidget | ||
| 23 | webViewWidget layout.Widget // The actual WebView widget | 25 | 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 realized | 83 | // 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.progressBar | 347 | 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_test | 1 | 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 | // Act | 50 | // Act | ||
| 50 | - | pv := component.NewPaneView(mockFactory, paneID, mockWebView) | 51 | + | pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView) |
| 51 | 52 | ||||
| 52 | // Assert | 53 | // 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 | // Act | 91 | // Act | ||
| 91 | - | pv := component.NewPaneView(mockFactory, paneID, nil) | 92 | + | pv := component.NewPaneView(context.Background(), mockFactory, paneID, nil) |
| 92 | 93 | ||||
| 93 | // Assert | 94 | // 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 added | 110 | // 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 activate | 131 | // 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 RemoveCssClass | 156 | // 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 | // Act | 175 | // 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 | // Act | 193 | // 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 new | 212 | // 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 | // Act | 276 | // 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 | // Act | 313 | // 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 | // Act | 331 | // 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 | // Act | 349 | // 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 component | 2 | 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.ProgressBarWidget | 30 | progressBar layout.ProgressBarWidget | ||
| 28 | 31 | ||||
| 29 | visible bool | 32 | 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 range | 96 | // Clamp progress to valid range | ||
| 80 | if progress < 0 { | 97 | if progress < 0 { | ||
| 81 | progress = 0 | 98 | 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 = true | 174 | 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 call | 185 | // 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 = false | 242 | 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 now | 87 | // Create PaneView without WebView widget for now | ||
| 88 | // WebView will be attached later by the application layer | 88 | // 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 lookup | 91 | // Store in map for later lookup | ||
| 92 | // Note: Caller (SetWorkspace) already holds the lock, so we access directly | 92 | // 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 sync | 77 | + | // 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 class | 81 | // 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 style | 91 | // 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.WebViewPool | 23 | pool port.WebViewPool | ||
| 22 | widgetFactory layout.WidgetFactory | 24 | widgetFactory layout.WidgetFactory | ||
| 23 | faviconAdapter *adapter.FaviconAdapter | 25 | faviconAdapter *adapter.FaviconAdapter | ||
| @@ -83,6 +85,7 @@ type Coordinator struct { | |||||
| 83 | factory port.WebViewFactory | 85 | factory port.WebViewFactory | ||
| 84 | popupConfig *entity.PopupBehaviorConfig | 86 | popupConfig *entity.PopupBehaviorConfig | ||
| 85 | pendingPopups map[port.WebViewID]*PendingPopup | 87 | pendingPopups map[port.WebViewID]*PendingPopup | ||
| 88 | + | namedPopups map[namedPopupKey]*namedPopupState | |||
| 86 | popupOAuth map[port.WebViewID]*popupOAuthState | 89 | popupOAuth map[port.WebViewID]*popupOAuthState | ||
| 87 | popupRefresh map[entity.PaneID]*time.Timer | 90 | popupRefresh map[entity.PaneID]*time.Timer | ||
| 88 | popupMu sync.RWMutex | 91 | 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 snapshots | 83 | // 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 | return | 196 | 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 | return | 212 | 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 | return | 239 | 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.Time | 79 | 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 nil | 300 | 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 entity | 326 | // 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 configuration | 329 | + | 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 != nil | 372 | hasConfig := c.popupConfig != nil | ||
| 251 | oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose | 373 | 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 popupWV | 462 | 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 ready | 489 | + | // 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 | return | 534 | 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 | return | 552 | 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 / capturePopupOAuthState | 185 | // 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 | return | 292 | 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 | return | 245 | 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 StackedView | 364 | // 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 pane | 536 | // 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 StackedView | 540 | // 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 pane | 1440 | // 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 nil | 2085 | 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.Engine | 39 | Engine port.Engine | ||
| 40 | 40 | ||||
| 41 | + | // Handler dependencies built by bootstrap (keybindings, config save). | |||
| 42 | + | HandlerDeps port.HandlerDeps | |||
| 43 | + | ||||
| 41 | // Repositories | 44 | // Repositories | ||
| 42 | HistoryRepo repository.HistoryRepository | 45 | HistoryRepo repository.HistoryRepository | ||
| 43 | FavoriteRepo repository.FavoriteRepository | 46 | 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 needed | 123 | // Use cases are optional - can be nil if not needed | ||
| 118 | return nil | 124 | 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 1 | 344 | return 1 | ||
| 346 | } | 345 | } | ||
| 347 | 346 | ||||
| 348 | - | adw.Init() | 347 | + | EnsureAdwaitaInitialized() |
| 349 | windowCfg := DefaultStandaloneOmniboxWindowConfig() | 348 | windowCfg := DefaultStandaloneOmniboxWindowConfig() | ||
| 350 | appID := windowCfg.Namespace | 349 | 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-any | 26 | // 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 objects | 58 | * - 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 set | 70 | // 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 API | 164 | // 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 active | 262 | // 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 Bridge | 2 | + | // Homepage bridge |
| 3 | - | // Communication layer between frontend and Go backend via WebKit message handlers | 3 | + | // 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 Callbacks | 84 | // 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 assets | 61 | // 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.css | 69 | 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> | ||