Merge branch 'feat/cef-engine-adapter'
154 files · +15560 -2107
M .gitignore +3 -0
| @@ -8,6 +8,7 @@ dist/ | |||
| 8 | 8 | ||
| 9 | 9 | # Binaries for programs and plugins | |
| 10 | 10 | /dumber | |
| 11 | + | /cef-helper | |
| 11 | 12 | dumber.exe | |
| 12 | 13 | ||
| 13 | 14 | # Test binary, built with `go test -c` | |
| @@ -42,6 +43,7 @@ Thumbs.db | |||
| 42 | 43 | *.tmp | |
| 43 | 44 | *.temp | |
| 44 | 45 | *.log | |
| 46 | + | __debug_bin* | |
| 45 | 47 | ||
| 46 | 48 | # Database files | |
| 47 | 49 | *.db | |
| @@ -116,3 +118,4 @@ aur/*/logo-512.png | |||
| 116 | 118 | ||
| 117 | 119 | # Planning/runbook notes stored outside product docs | |
| 118 | 120 | docs/reference/stability-performance-multi-agent-runbook-*.md | |
| 121 | + | perf.data | |
M .golangci.yml +10 -0
| @@ -146,6 +146,11 @@ linters: | |||
| 146 | 146 | - text: "G204:" | |
| 147 | 147 | linters: | |
| 148 | 148 | - gosec | |
| 149 | + | # CEF/OpenGL interop requires unsafe pointer arithmetic | |
| 150 | + | - path: internal/infrastructure/cef/ | |
| 151 | + | text: "G103:" | |
| 152 | + | linters: | |
| 153 | + | - gosec | |
| 149 | 154 | - text: "G304:" | |
| 150 | 155 | linters: | |
| 151 | 156 | - gosec | |
| @@ -171,6 +176,11 @@ linters: | |||
| 171 | 176 | text: "unused-receiver" | |
| 172 | 177 | linters: | |
| 173 | 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 | 185 | run: | |
| 176 | 186 | timeout: 20m | |
M .mockery.yaml +2 -1
| @@ -35,7 +35,8 @@ packages: | |||
| 35 | 35 | Engine: {} | |
| 36 | 36 | ContentInjector: {} | |
| 37 | 37 | SchemeHandler: {} | |
| 38 | - | MessageRouter: {} | |
| 38 | + | WebUIMessageHandler: {} | |
| 39 | + | WebUIHandlerRouter: {} | |
| 39 | 40 | SettingsApplier: {} | |
| 40 | 41 | FilterApplier: {} | |
| 41 | 42 | FaviconDatabase: {} | |
M Makefile +12 -2
| @@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |||
| 24 | 24 | # Linker flags | |
| 25 | 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 | 35 | # Default target | |
| 28 | 36 | help: ## Show this help message | |
| 29 | 37 | @echo "Available targets:" | |
| @@ -33,7 +41,8 @@ help: ## Show this help message | |||
| 33 | 41 | build: build-frontend ## Build the application (pure Go, no CGO) | |
| 34 | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 35 | 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 | 46 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 38 | 47 | ||
| 39 | 48 | build-frontend: ## Build homepage and error pages | |
| @@ -44,7 +53,8 @@ build-frontend: ## Build homepage and error pages | |||
| 44 | 53 | build-quick: ## Build without frontend (faster for backend development) | |
| 45 | 54 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 46 | 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 | 58 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 49 | 59 | ||
| 50 | 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 | 35 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 36 | 36 | "github.com/bnema/dumber/internal/ui/component" | |
| 37 | 37 | "github.com/bnema/dumber/internal/ui/theme" | |
| 38 | + | cef "github.com/bnema/purego-cef/cef" | |
| 38 | 39 | "github.com/bnema/puregotk/v4/gtk" | |
| 39 | 40 | "github.com/rs/zerolog" | |
| 40 | 41 | ) | |
| @@ -88,8 +89,18 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 88 | 89 | func main() { | |
| 89 | 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 | 104 | // Run GUI mode for browse command | |
| 94 | 105 | if mode == launchModeBrowse { | |
| 95 | 106 | initialURL = browseURL | |
| @@ -136,6 +147,11 @@ func runGUI() int { | |||
| 136 | 147 | } | |
| 137 | 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 | 155 | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | |
| 140 | 156 | ||
| 141 | 157 | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| @@ -194,6 +210,8 @@ func runStandaloneOmnibox() int { | |||
| 194 | 210 | return 1 | |
| 195 | 211 | } | |
| 196 | 212 | ||
| 213 | + | preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized) | |
| 214 | + | ||
| 197 | 215 | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |
| 198 | 216 | if err != nil { | |
| 199 | 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 | 224 | useCases := createUseCases(repos, cfg) | |
| 207 | - | uiDeps := buildUIDependencies( | |
| 225 | + | uiDeps, err := buildUIDependencies( | |
| 208 | 226 | ctx, | |
| 209 | 227 | cfg, | |
| 210 | 228 | initResult.ThemeManager, | |
| @@ -217,11 +235,29 @@ func runStandaloneOmnibox() int { | |||
| 217 | 235 | "", | |
| 218 | 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 | 242 | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |
| 221 | 243 | ||
| 222 | 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 | 261 | func maybeReexecStandaloneOmniboxWithLayerShell() { | |
| 226 | 262 | env := bootstrap.CurrentEnvMap() | |
| 227 | 263 | if !bootstrap.ShouldPreloadLayerShell(env) { | |
| @@ -295,10 +331,10 @@ func initBrowserSession( | |||
| 295 | 331 | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 296 | 332 | } | |
| 297 | 333 | cleanup := func() { | |
| 334 | + | _ = browserSession.End(sessionCtx) | |
| 298 | 335 | if browserSession.LogCleanup != nil { | |
| 299 | 336 | browserSession.LogCleanup() | |
| 300 | 337 | } | |
| 301 | - | _ = browserSession.End(sessionCtx) | |
| 302 | 338 | } | |
| 303 | 339 | return sessionCtx, browserSession, cleanup | |
| 304 | 340 | } | |
| @@ -319,11 +355,14 @@ func buildAndConfigureApp( | |||
| 319 | 355 | idleInhibitor port.IdleInhibitor, | |
| 320 | 356 | browserSession *bootstrap.BrowserSession, | |
| 321 | 357 | ) (*ui.App, error) { | |
| 322 | - | uiDeps := buildUIDependencies( | |
| 358 | + | uiDeps, err := buildUIDependencies( | |
| 323 | 359 | ctx, cfg, initResult.ThemeManager, | |
| 324 | 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 | 366 | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 328 | 367 | return ui.New(uiDeps) | |
| 329 | 368 | } | |
| @@ -424,7 +463,7 @@ func configureDeferredInit( | |||
| 424 | 463 | logger.Error().Err(persistErr).Msg("deferred session persistence failed") | |
| 425 | 464 | } else { | |
| 426 | 465 | if uiDeps.OnCrashReportsDetected != nil { | |
| 427 | - | if reports := session.UnexpectedCloseReports(); len(reports) > 0 { | |
| 466 | + | if reports := session.CrashReports(); len(reports) > 0 { | |
| 428 | 467 | uiDeps.OnCrashReportsDetected(reports) | |
| 429 | 468 | } | |
| 430 | 469 | } | |
| @@ -502,13 +541,20 @@ func handleAutoRestore( | |||
| 502 | 541 | ||
| 503 | 542 | func setupSignalHandler(ctx context.Context, app *ui.App) { | |
| 504 | 543 | log := logging.FromContext(ctx) | |
| 505 | - | sigCh := make(chan os.Signal, 1) | |
| 544 | + | sigCh := make(chan os.Signal, 2) | |
| 506 | 545 | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 507 | 546 | go func() { | |
| 508 | 547 | sig := <-sigCh | |
| 509 | - | signal.Stop(sigCh) | |
| 510 | - | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting") | |
| 548 | + | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting gracefully") | |
| 511 | 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 | 652 | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | |
| 607 | 653 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 608 | 654 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | |
| 609 | - | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | |
| 655 | + | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState), | |
| 610 | 656 | checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo), | |
| 611 | 657 | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome), | |
| 612 | 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 | 674 | func buildUIDependencies( | |
| 618 | 675 | ctx context.Context, | |
| 619 | 676 | cfg *config.Config, | |
| @@ -626,12 +683,17 @@ func buildUIDependencies( | |||
| 626 | 683 | idleInhibitor port.IdleInhibitor, | |
| 627 | 684 | currentSessionID entity.SessionID, | |
| 628 | 685 | startupCrashReports []string, | |
| 629 | - | ) *ui.Dependencies { | |
| 686 | + | ) (*ui.Dependencies, error) { | |
| 630 | 687 | var filterManager port.FilterManager | |
| 631 | 688 | if fmp, ok := engine.(port.FilterManagerProvider); ok { | |
| 632 | 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 | 697 | focusProvider := textinput.NewFocusProvider() | |
| 636 | 698 | ||
| 637 | 699 | uiDeps := &ui.Dependencies{ | |
| @@ -699,7 +761,8 @@ func buildUIDependencies( | |||
| 699 | 761 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 700 | 762 | LaunchBrowserURL: desktop.LaunchBrowserURL, | |
| 701 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 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 | 12 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |
| @@ -87,3 +91,43 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |||
| 87 | 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 | 3 | go 1.26.1 | |
| 4 | 4 | ||
| 5 | 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 | 9 | github.com/bnema/purego-sqlite v0.1.1 | |
| 7 | 10 | github.com/bnema/puregotk v0.3.0 | |
| 8 | 11 | github.com/bnema/puregotk-webkit v0.0.16 | |
| @@ -12,6 +15,7 @@ require ( | |||
| 12 | 15 | github.com/ebitengine/purego v0.10.0 | |
| 13 | 16 | github.com/fsnotify/fsnotify v1.9.0 | |
| 14 | 17 | github.com/godbus/dbus/v5 v5.2.2 | |
| 18 | + | github.com/google/uuid v1.6.0 | |
| 15 | 19 | github.com/pelletier/go-toml/v2 v2.3.0 | |
| 16 | 20 | github.com/pressly/goose/v3 v3.27.0 | |
| 17 | 21 | github.com/rs/zerolog v1.34.0 | |
| @@ -23,8 +27,6 @@ require ( | |||
| 23 | 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 | 30 | require ( | |
| 29 | 31 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 30 | 32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| @@ -66,4 +68,7 @@ require ( | |||
| 66 | 68 | golang.org/x/sync v0.20.0 // indirect | |
| 67 | 69 | golang.org/x/text v0.35.0 // indirect | |
| 68 | 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 | 3 | // remain independent of specific implementations (WebKit, GTK, etc.). | |
| 4 | 4 | package port | |
| 5 | 5 | ||
| 6 | - | import "context" | |
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | ) | |
| 7 | 10 | ||
| 8 | 11 | // CookiePolicy controls cookie acceptance behavior for the engine's network session. | |
| 9 | 12 | type CookiePolicy string | |
| @@ -157,6 +160,37 @@ type EngineSettingsUpdate struct { | |||
| 157 | 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 | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | |
| 161 | 195 | type HandlerDependencies struct { | |
| 162 | 196 | HistoryUC HomepageHistory | |
| @@ -164,6 +198,7 @@ type HandlerDependencies struct { | |||
| 164 | 198 | Clipboard Clipboard | |
| 165 | 199 | AutoCopyConfig AutoCopyConfig | |
| 166 | 200 | OnClipboardCopied func(textLen int) | |
| 201 | + | HandlerDeps | |
| 167 | 202 | } | |
| 168 | 203 | ||
| 169 | 204 | // SchemeHandler defines the port interface for registering custom URI schemes. | |
| @@ -176,19 +211,6 @@ type SchemeHandler interface { | |||
| 176 | 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 | 214 | // SettingsApplier defines the port interface for applying browser settings to WebViews. | |
| 193 | 215 | // Implementations apply engine-specific settings (security, features, etc.) uniformly | |
| 194 | 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 | 5 | // FaviconService provides favicon retrieval and caching. | |
| 6 | 6 | type FaviconService interface { | |
| 7 | - | GetCached(domain string) ([]byte, bool) | |
| 7 | + | GetCached(ctx context.Context, domain string) ([]byte, bool) | |
| 8 | 8 | Get(ctx context.Context, domain string) ([]byte, error) | |
| 9 | 9 | DiskPathPNG(domain string) string | |
| 10 | 10 | HasPNGOnDisk(domain string) bool | |
M internal/application/port/handler_deps.go +15 -0
| @@ -30,3 +30,18 @@ type DownloadPrepareOutput struct { | |||
| 30 | 30 | type AutoCopyConfig interface { | |
| 31 | 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 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | "database/sql" | |
| 10 | + | "encoding/json" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -3395,13 +3396,13 @@ func (_c *MockEngine_UpdateSettings_Call) RunAndReturn(run func(ctx context.Cont | |||
| 3395 | 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 | 3400 | // The first argument is typically a *testing.T value. | |
| 3400 | - | func NewMockSchemeHandler(t interface { | |
| 3401 | + | func NewMockWebUIMessageHandler(t interface { | |
| 3401 | 3402 | mock.TestingT | |
| 3402 | 3403 | Cleanup(func()) | |
| 3403 | - | }) *MockSchemeHandler { | |
| 3404 | - | mock := &MockSchemeHandler{} | |
| 3404 | + | }) *MockWebUIMessageHandler { | |
| 3405 | + | mock := &MockWebUIMessageHandler{} | |
| 3405 | 3406 | mock.Mock.Test(t) | |
| 3406 | 3407 | ||
| 3407 | 3408 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -3409,72 +3410,100 @@ func NewMockSchemeHandler(t interface { | |||
| 3409 | 3410 | return mock | |
| 3410 | 3411 | } | |
| 3411 | 3412 | ||
| 3412 | - | // MockSchemeHandler is an autogenerated mock type for the SchemeHandler type | |
| 3413 | - | type MockSchemeHandler struct { | |
| 3413 | + | // MockWebUIMessageHandler is an autogenerated mock type for the WebUIMessageHandler type | |
| 3414 | + | type MockWebUIMessageHandler struct { | |
| 3414 | 3415 | mock.Mock | |
| 3415 | 3416 | } | |
| 3416 | 3417 | ||
| 3417 | - | type MockSchemeHandler_Expecter struct { | |
| 3418 | + | type MockWebUIMessageHandler_Expecter struct { | |
| 3418 | 3419 | mock *mock.Mock | |
| 3419 | 3420 | } | |
| 3420 | 3421 | ||
| 3421 | - | func (_m *MockSchemeHandler) EXPECT() *MockSchemeHandler_Expecter { | |
| 3422 | - | return &MockSchemeHandler_Expecter{mock: &_m.Mock} | |
| 3422 | + | func (_m *MockWebUIMessageHandler) EXPECT() *MockWebUIMessageHandler_Expecter { | |
| 3423 | + | return &MockWebUIMessageHandler_Expecter{mock: &_m.Mock} | |
| 3423 | 3424 | } | |
| 3424 | 3425 | ||
| 3425 | - | // RegisterScheme provides a mock function for the type MockSchemeHandler | |
| 3426 | - | func (_mock *MockSchemeHandler) RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error)) { | |
| 3427 | - | _mock.Called(scheme, handler) | |
| 3428 | - | return | |
| 3426 | + | // Handle provides a mock function for the type MockWebUIMessageHandler | |
| 3427 | + | func (_mock *MockWebUIMessageHandler) Handle(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 3428 | + | ret := _mock.Called(ctx, webviewID, payload) | |
| 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' | |
| 3432 | - | type MockSchemeHandler_RegisterScheme_Call struct { | |
| 3454 | + | // MockWebUIMessageHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle' | |
| 3455 | + | type MockWebUIMessageHandler_Handle_Call struct { | |
| 3433 | 3456 | *mock.Call | |
| 3434 | 3457 | } | |
| 3435 | 3458 | ||
| 3436 | - | // RegisterScheme is a helper method to define mock.On call | |
| 3437 | - | // - scheme string | |
| 3438 | - | // - handler func(uri string) ([]byte, string, error) | |
| 3439 | - | func (_e *MockSchemeHandler_Expecter) RegisterScheme(scheme interface{}, handler interface{}) *MockSchemeHandler_RegisterScheme_Call { | |
| 3440 | - | return &MockSchemeHandler_RegisterScheme_Call{Call: _e.mock.On("RegisterScheme", scheme, handler)} | |
| 3459 | + | // Handle is a helper method to define mock.On call | |
| 3460 | + | // - ctx context.Context | |
| 3461 | + | // - webviewID port.WebViewID | |
| 3462 | + | // - payload json.RawMessage | |
| 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 | 3468 | _c.Call.Run(func(args mock.Arguments) { | |
| 3445 | - | var arg0 string | |
| 3469 | + | var arg0 context.Context | |
| 3446 | 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 | 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 | 3481 | run( | |
| 3454 | 3482 | arg0, | |
| 3455 | 3483 | arg1, | |
| 3484 | + | arg2, | |
| 3456 | 3485 | ) | |
| 3457 | 3486 | }) | |
| 3458 | 3487 | return _c | |
| 3459 | 3488 | } | |
| 3460 | 3489 | ||
| 3461 | - | func (_c *MockSchemeHandler_RegisterScheme_Call) Return() *MockSchemeHandler_RegisterScheme_Call { | |
| 3462 | - | _c.Call.Return() | |
| 3490 | + | func (_c *MockWebUIMessageHandler_Handle_Call) Return(v any, err error) *MockWebUIMessageHandler_Handle_Call { | |
| 3491 | + | _c.Call.Return(v, err) | |
| 3463 | 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 { | |
| 3467 | - | _c.Run(run) | |
| 3495 | + | func (_c *MockWebUIMessageHandler_Handle_Call) RunAndReturn(run func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error)) *MockWebUIMessageHandler_Handle_Call { | |
| 3496 | + | _c.Call.Return(run) | |
| 3468 | 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 | 3501 | // The first argument is typically a *testing.T value. | |
| 3473 | - | func NewMockMessageRouter(t interface { | |
| 3502 | + | func NewMockWebUIHandlerRouter(t interface { | |
| 3474 | 3503 | mock.TestingT | |
| 3475 | 3504 | Cleanup(func()) | |
| 3476 | - | }) *MockMessageRouter { | |
| 3477 | - | mock := &MockMessageRouter{} | |
| 3505 | + | }) *MockWebUIHandlerRouter { | |
| 3506 | + | mock := &MockWebUIHandlerRouter{} | |
| 3478 | 3507 | mock.Mock.Test(t) | |
| 3479 | 3508 | ||
| 3480 | 3509 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -3482,103 +3511,205 @@ func NewMockMessageRouter(t interface { | |||
| 3482 | 3511 | return mock | |
| 3483 | 3512 | } | |
| 3484 | 3513 | ||
| 3485 | - | // MockMessageRouter is an autogenerated mock type for the MessageRouter type | |
| 3486 | - | type MockMessageRouter struct { | |
| 3514 | + | // MockWebUIHandlerRouter is an autogenerated mock type for the WebUIHandlerRouter type | |
| 3515 | + | type MockWebUIHandlerRouter struct { | |
| 3487 | 3516 | mock.Mock | |
| 3488 | 3517 | } | |
| 3489 | 3518 | ||
| 3490 | - | type MockMessageRouter_Expecter struct { | |
| 3519 | + | type MockWebUIHandlerRouter_Expecter struct { | |
| 3491 | 3520 | mock *mock.Mock | |
| 3492 | 3521 | } | |
| 3493 | 3522 | ||
| 3494 | - | func (_m *MockMessageRouter) EXPECT() *MockMessageRouter_Expecter { | |
| 3495 | - | return &MockMessageRouter_Expecter{mock: &_m.Mock} | |
| 3523 | + | func (_m *MockWebUIHandlerRouter) EXPECT() *MockWebUIHandlerRouter_Expecter { | |
| 3524 | + | return &MockWebUIHandlerRouter_Expecter{mock: &_m.Mock} | |
| 3496 | 3525 | } | |
| 3497 | 3526 | ||
| 3498 | - | // PostMessage provides a mock function for the type MockMessageRouter | |
| 3499 | - | func (_mock *MockMessageRouter) PostMessage(webviewID port.WebViewID, message string) error { | |
| 3500 | - | ret := _mock.Called(webviewID, message) | |
| 3527 | + | // RegisterHandler provides a mock function for the type MockWebUIHandlerRouter | |
| 3528 | + | func (_mock *MockWebUIHandlerRouter) RegisterHandler(msgType string, handler port.WebUIMessageHandler) error { | |
| 3529 | + | ret := _mock.Called(msgType, handler) | |
| 3501 | 3530 | ||
| 3502 | 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 | 3535 | var r0 error | |
| 3507 | - | if returnFunc, ok := ret.Get(0).(func(port.WebViewID, string) error); ok { | |
| 3508 | - | r0 = returnFunc(webviewID, message) | |
| 3536 | + | if returnFunc, ok := ret.Get(0).(func(string, port.WebUIMessageHandler) error); ok { | |
| 3537 | + | r0 = returnFunc(msgType, handler) | |
| 3509 | 3538 | } else { | |
| 3510 | 3539 | r0 = ret.Error(0) | |
| 3511 | 3540 | } | |
| 3512 | 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' | |
| 3516 | - | type MockMessageRouter_PostMessage_Call struct { | |
| 3544 | + | // MockWebUIHandlerRouter_RegisterHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterHandler' | |
| 3545 | + | type MockWebUIHandlerRouter_RegisterHandler_Call struct { | |
| 3517 | 3546 | *mock.Call | |
| 3518 | 3547 | } | |
| 3519 | 3548 | ||
| 3520 | - | // PostMessage is a helper method to define mock.On call | |
| 3521 | - | // - webviewID port.WebViewID | |
| 3522 | - | // - message string | |
| 3523 | - | func (_e *MockMessageRouter_Expecter) PostMessage(webviewID interface{}, message interface{}) *MockMessageRouter_PostMessage_Call { | |
| 3524 | - | return &MockMessageRouter_PostMessage_Call{Call: _e.mock.On("PostMessage", webviewID, message)} | |
| 3549 | + | // RegisterHandler is a helper method to define mock.On call | |
| 3550 | + | // - msgType string | |
| 3551 | + | // - handler port.WebUIMessageHandler | |
| 3552 | + | func (_e *MockWebUIHandlerRouter_Expecter) RegisterHandler(msgType interface{}, handler interface{}) *MockWebUIHandlerRouter_RegisterHandler_Call { | |
| 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 | 3617 | _c.Call.Run(func(args mock.Arguments) { | |
| 3529 | - | var arg0 port.WebViewID | |
| 3618 | + | var arg0 string | |
| 3530 | 3619 | if args[0] != nil { | |
| 3531 | - | arg0 = args[0].(port.WebViewID) | |
| 3620 | + | arg0 = args[0].(string) | |
| 3532 | 3621 | } | |
| 3533 | 3622 | var arg1 string | |
| 3534 | 3623 | if args[1] != nil { | |
| 3535 | 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 | 3638 | run( | |
| 3538 | 3639 | arg0, | |
| 3539 | 3640 | arg1, | |
| 3641 | + | arg2, | |
| 3642 | + | arg3, | |
| 3643 | + | arg4, | |
| 3540 | 3644 | ) | |
| 3541 | 3645 | }) | |
| 3542 | 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 | 3650 | _c.Call.Return(err) | |
| 3547 | 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 | 3655 | _c.Call.Return(run) | |
| 3552 | 3656 | return _c | |
| 3553 | 3657 | } | |
| 3554 | 3658 | ||
| 3555 | - | // RegisterHandler provides a mock function for the type MockMessageRouter | |
| 3556 | - | func (_mock *MockMessageRouter) RegisterHandler(name string, handler func(message string) (string, error)) { | |
| 3557 | - | _mock.Called(name, handler) | |
| 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. | |
| 3660 | + | // The first argument is typically a *testing.T value. | |
| 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 | 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' | |
| 3562 | - | type MockMessageRouter_RegisterHandler_Call struct { | |
| 3692 | + | // MockSchemeHandler_RegisterScheme_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterScheme' | |
| 3693 | + | type MockSchemeHandler_RegisterScheme_Call struct { | |
| 3563 | 3694 | *mock.Call | |
| 3564 | 3695 | } | |
| 3565 | 3696 | ||
| 3566 | - | // RegisterHandler is a helper method to define mock.On call | |
| 3567 | - | // - name string | |
| 3568 | - | // - handler func(message string) (string, error) | |
| 3569 | - | func (_e *MockMessageRouter_Expecter) RegisterHandler(name interface{}, handler interface{}) *MockMessageRouter_RegisterHandler_Call { | |
| 3570 | - | return &MockMessageRouter_RegisterHandler_Call{Call: _e.mock.On("RegisterHandler", name, handler)} | |
| 3697 | + | // RegisterScheme is a helper method to define mock.On call | |
| 3698 | + | // - scheme string | |
| 3699 | + | // - handler func(uri string) ([]byte, string, error) | |
| 3700 | + | func (_e *MockSchemeHandler_Expecter) RegisterScheme(scheme interface{}, handler interface{}) *MockSchemeHandler_RegisterScheme_Call { | |
| 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 | 3705 | _c.Call.Run(func(args mock.Arguments) { | |
| 3575 | 3706 | var arg0 string | |
| 3576 | 3707 | if args[0] != nil { | |
| 3577 | 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 | 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 | 3714 | run( | |
| 3584 | 3715 | arg0, | |
| @@ -3588,12 +3719,12 @@ func (_c *MockMessageRouter_RegisterHandler_Call) Run(run func(name string, hand | |||
| 3588 | 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 | 3723 | _c.Call.Return() | |
| 3593 | 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 | 3728 | _c.Run(run) | |
| 3598 | 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 | 4427 | // GetCached provides a mock function for the type MockFaviconService | |
| 4297 | - | func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | |
| 4298 | - | ret := _mock.Called(domain) | |
| 4428 | + | func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) { | |
| 4429 | + | ret := _mock.Called(ctx, domain) | |
| 4299 | 4430 | ||
| 4300 | 4431 | if len(ret) == 0 { | |
| 4301 | 4432 | panic("no return value specified for GetCached") | |
| @@ -4303,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | |||
| 4303 | 4434 | ||
| 4304 | 4435 | var r0 []byte | |
| 4305 | 4436 | var r1 bool | |
| 4306 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok { | |
| 4307 | - | return returnFunc(domain) | |
| 4437 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok { | |
| 4438 | + | return returnFunc(ctx, domain) | |
| 4308 | 4439 | } | |
| 4309 | - | if returnFunc, ok := ret.Get(0).(func(string) []byte); ok { | |
| 4310 | - | r0 = returnFunc(domain) | |
| 4440 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok { | |
| 4441 | + | r0 = returnFunc(ctx, domain) | |
| 4311 | 4442 | } else { | |
| 4312 | 4443 | if ret.Get(0) != nil { | |
| 4313 | 4444 | r0 = ret.Get(0).([]byte) | |
| 4314 | 4445 | } | |
| 4315 | 4446 | } | |
| 4316 | - | if returnFunc, ok := ret.Get(1).(func(string) bool); ok { | |
| 4317 | - | r1 = returnFunc(domain) | |
| 4447 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |
| 4448 | + | r1 = returnFunc(ctx, domain) | |
| 4318 | 4449 | } else { | |
| 4319 | 4450 | r1 = ret.Get(1).(bool) | |
| 4320 | 4451 | } | |
| @@ -4327,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct { | |||
| 4327 | 4458 | } | |
| 4328 | 4459 | ||
| 4329 | 4460 | // GetCached is a helper method to define mock.On call | |
| 4461 | + | // - ctx context.Context | |
| 4330 | 4462 | // - domain string | |
| 4331 | - | func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call { | |
| 4332 | - | return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)} | |
| 4463 | + | func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call { | |
| 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 | 4468 | _c.Call.Run(func(args mock.Arguments) { | |
| 4337 | - | var arg0 string | |
| 4469 | + | var arg0 context.Context | |
| 4338 | 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 | 4477 | run( | |
| 4342 | 4478 | arg0, | |
| 4479 | + | arg1, | |
| 4343 | 4480 | ) | |
| 4344 | 4481 | }) | |
| 4345 | 4482 | return _c | |
| @@ -4350,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF | |||
| 4350 | 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 | 4491 | _c.Call.Return(run) | |
| 4355 | 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 | 19 | type DeleteSessionUseCase struct { | |
| 20 | 20 | stateRepo repository.SessionStateRepository | |
| 21 | 21 | sessionRepo repository.SessionRepository | |
| 22 | - | lockDir string | |
| 23 | 22 | } | |
| 24 | 23 | ||
| 25 | 24 | // NewDeleteSessionUseCase creates a new DeleteSessionUseCase. | |
| 26 | 25 | func NewDeleteSessionUseCase( | |
| 27 | 26 | stateRepo repository.SessionStateRepository, | |
| 28 | 27 | sessionRepo repository.SessionRepository, | |
| 29 | - | lockDir string, | |
| 30 | 28 | ) *DeleteSessionUseCase { | |
| 31 | 29 | return &DeleteSessionUseCase{ | |
| 32 | 30 | stateRepo: stateRepo, | |
| 33 | 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 | 57 | return ErrSessionNotFound | |
| 61 | 58 | } | |
| 62 | 59 | ||
| 63 | - | // Check not active (EndedAt is nil OR has lock file) | |
| 64 | - | if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) { | |
| 60 | + | // Don't delete active sessions. | |
| 61 | + | if session.IsActive() { | |
| 65 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | - | "os" | |
| 6 | - | "path/filepath" | |
| 7 | 5 | "testing" | |
| 8 | 6 | "time" | |
| 9 | 7 | ||
| @@ -34,7 +32,7 @@ func TestDeleteSessionUseCase_Execute_Success(t *testing.T) { | |||
| 34 | 32 | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 35 | 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 | 37 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 40 | 38 | SessionID: sessionID, | |
| @@ -51,7 +49,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) { | |||
| 51 | 49 | ||
| 52 | 50 | sessionID := entity.SessionID("20251224_120000_current") | |
| 53 | 51 | ||
| 54 | - | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 52 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo) | |
| 55 | 53 | ||
| 56 | 54 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 57 | 55 | SessionID: sessionID, | |
| @@ -71,7 +69,7 @@ func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |||
| 71 | 69 | ||
| 72 | 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 | 74 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 77 | 75 | SessionID: sessionID, | |
| @@ -99,7 +97,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) { | |||
| 99 | 97 | ||
| 100 | 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 | 102 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 105 | 103 | SessionID: sessionID, | |
| @@ -109,40 +107,6 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) { | |||
| 109 | 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 | 110 | func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) { | |
| 147 | 111 | ctx := testContext() | |
| 148 | 112 | ||
| @@ -153,7 +117,7 @@ func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) { | |||
| 153 | 117 | ||
| 154 | 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 | 122 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 159 | 123 | SessionID: sessionID, | |
| @@ -184,7 +148,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) { | |||
| 184 | 148 | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows")) | |
| 185 | 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 | 153 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 190 | 154 | SessionID: sessionID, | |
| @@ -214,7 +178,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) { | |||
| 214 | 178 | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 215 | 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 | 183 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 220 | 184 | SessionID: sessionID, | |
| @@ -244,7 +208,7 @@ func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) { | |||
| 244 | 208 | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 245 | 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 | 213 | // CurrentSessionID is empty (e.g., CLI context) | |
| 250 | 214 | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
M internal/application/usecase/get_last_restorable_session.go +2 -6
| @@ -21,19 +21,16 @@ const ( | |||
| 21 | 21 | type GetLastRestorableSessionUseCase struct { | |
| 22 | 22 | sessionRepo repository.SessionRepository | |
| 23 | 23 | stateRepo repository.SessionStateRepository | |
| 24 | - | lockDir string | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 27 | 26 | // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase. | |
| 28 | 27 | func NewGetLastRestorableSessionUseCase( | |
| 29 | 28 | sessionRepo repository.SessionRepository, | |
| 30 | 29 | stateRepo repository.SessionStateRepository, | |
| 31 | - | lockDir string, | |
| 32 | 30 | ) *GetLastRestorableSessionUseCase { | |
| 33 | 31 | return &GetLastRestorableSessionUseCase{ | |
| 34 | 32 | sessionRepo: sessionRepo, | |
| 35 | 33 | stateRepo: stateRepo, | |
| 36 | - | lockDir: lockDir, | |
| 37 | 34 | } | |
| 38 | 35 | } | |
| 39 | 36 | ||
| @@ -75,9 +72,8 @@ func (uc *GetLastRestorableSessionUseCase) Execute( | |||
| 75 | 72 | continue | |
| 76 | 73 | } | |
| 77 | 74 | ||
| 78 | - | // Check if session is actively running | |
| 79 | - | // A session is "active" if ended_at IS NULL AND it has a lock file | |
| 80 | - | if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) { | |
| 75 | + | // Skip sessions that haven't been ended yet (still running or crashed). | |
| 76 | + | if session.IsActive() { | |
| 81 | 77 | log.Debug(). | |
| 82 | 78 | Str("session_id", string(session.ID)). | |
| 83 | 79 | Msg("auto-restore: skipping active session") | |
M internal/application/usecase/get_last_restorable_session_test.go +14 -27
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package usecase_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | 4 | "testing" | |
| 7 | 5 | "time" | |
| 8 | 6 | ||
| @@ -21,7 +19,7 @@ func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) { | |||
| 21 | 19 | ||
| 22 | 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 | 24 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 27 | 25 | ExcludeSessionID: "current", | |
| @@ -53,7 +51,7 @@ func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) { | |||
| 53 | 51 | ||
| 54 | 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 | 56 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 59 | 57 | ExcludeSessionID: currentID, | |
| @@ -82,7 +80,7 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) { | |||
| 82 | 80 | ||
| 83 | 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 | 85 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 88 | 86 | ExcludeSessionID: "current", | |
| @@ -92,23 +90,14 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) { | |||
| 92 | 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 | 94 | ctx := testContext() | |
| 97 | 95 | ||
| 98 | - | // Create temp dir for lock files | |
| 99 | - | lockDir := t.TempDir() | |
| 100 | - | ||
| 101 | 96 | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 102 | 97 | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 103 | 98 | ||
| 104 | 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 | 101 | sessions := []*entity.Session{ | |
| 113 | 102 | { | |
| 114 | 103 | ID: activeID, | |
| @@ -120,7 +109,7 @@ func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) { | |||
| 120 | 109 | ||
| 121 | 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 | 114 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 126 | 115 | ExcludeSessionID: "current", | |
| @@ -151,7 +140,7 @@ func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) { | |||
| 151 | 140 | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 152 | 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 | 145 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 157 | 146 | ExcludeSessionID: "current", | |
| @@ -189,7 +178,7 @@ func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) { | |||
| 189 | 178 | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 190 | 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 | 183 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 195 | 184 | ExcludeSessionID: "current", | |
| @@ -241,7 +230,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) { | |||
| 241 | 230 | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 242 | 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 | 235 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 247 | 236 | ExcludeSessionID: "current", | |
| @@ -256,21 +245,19 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) { | |||
| 256 | 245 | func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) { | |
| 257 | 246 | ctx := testContext() | |
| 258 | 247 | ||
| 259 | - | // Empty lock dir (no lock files) | |
| 260 | - | lockDir := t.TempDir() | |
| 261 | - | ||
| 262 | 248 | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 263 | 249 | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 264 | 250 | ||
| 265 | 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 | 255 | sessions := []*entity.Session{ | |
| 269 | 256 | { | |
| 270 | 257 | ID: sessionID, | |
| 271 | 258 | Type: entity.SessionTypeBrowser, | |
| 272 | 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 | 285 | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 299 | 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 | 290 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 304 | 291 | ExcludeSessionID: "current", | |
| @@ -349,7 +336,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) { | |||
| 349 | 336 | stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil) | |
| 350 | 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 | 341 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 355 | 342 | ExcludeSessionID: "current", | |
| @@ -397,7 +384,7 @@ func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) { | |||
| 397 | 384 | stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot | |
| 398 | 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 | 389 | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 403 | 390 | ExcludeSessionID: "current", | |
M internal/application/usecase/list_sessions.go +2 -10
| @@ -13,19 +13,16 @@ import ( | |||
| 13 | 13 | type ListSessionsUseCase struct { | |
| 14 | 14 | sessionRepo repository.SessionRepository | |
| 15 | 15 | stateRepo repository.SessionStateRepository | |
| 16 | - | lockDir string // Directory where lock files are stored | |
| 17 | 16 | } | |
| 18 | 17 | ||
| 19 | 18 | // NewListSessionsUseCase creates a new ListSessionsUseCase. | |
| 20 | 19 | func NewListSessionsUseCase( | |
| 21 | 20 | sessionRepo repository.SessionRepository, | |
| 22 | 21 | stateRepo repository.SessionStateRepository, | |
| 23 | - | lockDir string, | |
| 24 | 22 | ) *ListSessionsUseCase { | |
| 25 | 23 | return &ListSessionsUseCase{ | |
| 26 | 24 | sessionRepo: sessionRepo, | |
| 27 | 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 | 70 | info := entity.SessionInfo{ | |
| 74 | 71 | Session: session, | |
| 75 | 72 | IsCurrent: session.ID == currentSessionID, | |
| 76 | - | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 73 | + | IsActive: session.IsActive(), | |
| 77 | 74 | } | |
| 78 | 75 | ||
| 79 | 76 | if state, ok := snapshotMap[session.ID]; ok { | |
| @@ -94,11 +91,6 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent | |||
| 94 | 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 | 94 | // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending. | |
| 103 | 95 | func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) { | |
| 104 | 96 | // Simple bubble sort for small lists | |
| @@ -149,7 +141,7 @@ func (uc *ListSessionsUseCase) GetSessionInfo( | |||
| 149 | 141 | info := &entity.SessionInfo{ | |
| 150 | 142 | Session: session, | |
| 151 | 143 | IsCurrent: session.ID == currentSessionID, | |
| 152 | - | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 144 | + | IsActive: session.IsActive(), | |
| 153 | 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 | 47 | sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil) | |
| 48 | 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 | 52 | output, err := uc.Execute(ctx, "", 50) | |
| 53 | 53 | require.NoError(t, err) | |
| @@ -103,7 +103,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing | |||
| 103 | 103 | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 104 | 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 | 108 | output, err := uc.GetPurgeableSessions(ctx) | |
| 109 | 109 | require.NoError(t, err) | |
| @@ -139,7 +139,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing. | |||
| 139 | 139 | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 140 | 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 | 144 | output, err := uc.GetPurgeableSessions(ctx) | |
| 145 | 145 | require.NoError(t, err) | |
| @@ -167,7 +167,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing | |||
| 167 | 167 | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 168 | 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 | 172 | output, err := uc.GetPurgeableSessions(ctx) | |
| 173 | 173 | // Should succeed even if size query fails | |
M internal/application/usecase/navigate.go +71 -29
| @@ -32,6 +32,7 @@ const ( | |||
| 32 | 32 | // historyRecord holds data for async history recording. | |
| 33 | 33 | type historyRecord struct { | |
| 34 | 34 | url string | |
| 35 | + | title string // non-empty for title-update-only records | |
| 35 | 36 | visits int | |
| 36 | 37 | } | |
| 37 | 38 | ||
| @@ -227,23 +228,57 @@ func (uc *NavigateUseCase) historyWorker() { | |||
| 227 | 228 | ticker := time.NewTicker(historyWorkerFlushInterval) | |
| 228 | 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 | 243 | flushPending := func() { | |
| 233 | - | if len(pending) == 0 { | |
| 244 | + | visitCount := len(pendingVisits) | |
| 245 | + | titleCount := len(pendingTitles) | |
| 246 | + | if visitCount == 0 && titleCount == 0 { | |
| 234 | 247 | return | |
| 235 | 248 | } | |
| 236 | - | for historyURL, visits := range pending { | |
| 237 | - | uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits}) | |
| 249 | + | ||
| 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 | 277 | drainQueue := func() { | |
| 243 | 278 | for { | |
| 244 | 279 | select { | |
| 245 | 280 | case record := <-uc.historyQueue: | |
| 246 | - | pending[record.url] += record.visits | |
| 281 | + | processRecord(record) | |
| 247 | 282 | default: | |
| 248 | 283 | return | |
| 249 | 284 | } | |
| @@ -253,7 +288,7 @@ func (uc *NavigateUseCase) historyWorker() { | |||
| 253 | 288 | for { | |
| 254 | 289 | select { | |
| 255 | 290 | case record := <-uc.historyQueue: | |
| 256 | - | pending[record.url] += record.visits | |
| 291 | + | processRecord(record) | |
| 257 | 292 | case <-ticker.C: | |
| 258 | 293 | flushPending() | |
| 259 | 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. | |
| 316 | - | func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error { | |
| 350 | + | // persistTitleUpdate writes a title update to the database. | |
| 351 | + | // Called from the background worker goroutine. | |
| 352 | + | func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) { | |
| 317 | 353 | log := logging.FromContext(ctx) | |
| 318 | 354 | ||
| 319 | - | // Canonicalize URL the same way history records are persisted. | |
| 320 | - | historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{ | |
| 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 | |
| 355 | + | if title == "" { | |
| 356 | + | return | |
| 326 | 357 | } | |
| 327 | - | log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title") | |
| 328 | 358 | ||
| 329 | 359 | entry, err := uc.historyRepo.FindByURL(ctx, historyURL) | |
| 330 | 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 | 364 | if entry == nil { | |
| 335 | - | // Entry doesn't exist - don't create it here | |
| 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 | |
| 365 | + | return | |
| 340 | 366 | } | |
| 341 | 367 | ||
| 342 | - | // Update existing entry's title | |
| 343 | 368 | entry.Title = title | |
| 344 | 369 | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | |
| 345 | 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 | 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 | 399 | // Reload reloads the current page. | |
M internal/application/usecase/navigate_test.go +68 -2
| @@ -3,7 +3,9 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 11 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| @@ -79,13 +81,77 @@ func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testi | |||
| 79 | 81 | require.NotNil(t, before) | |
| 80 | 82 | ||
| 81 | 83 | uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault) | |
| 82 | - | err = uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New") | |
| 83 | - | require.NoError(t, err) | |
| 84 | + | uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New") | |
| 85 | + | uc.Close() | |
| 84 | 86 | ||
| 85 | 87 | after, err := repo.FindByURL(ctx, "https://example.com/article") | |
| 86 | 88 | require.NoError(t, err) | |
| 87 | 89 | require.NotNil(t, after) | |
| 88 | 90 | require.Equal(t, "New", after.Title) | |
| 89 | 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 | 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 | 11 | "regexp" | |
| 12 | 12 | "runtime" | |
| 13 | 13 | "sort" | |
| 14 | - | "strconv" | |
| 15 | 14 | "strings" | |
| 16 | 15 | "time" | |
| 17 | 16 | ||
| @@ -25,23 +24,20 @@ const ( | |||
| 25 | 24 | scannerMaxTokenSize = 256 * 1024 | |
| 26 | 25 | ) | |
| 27 | 26 | ||
| 28 | - | type unexpectedCloseReport struct { | |
| 29 | - | ReportVersion int `json:"report_version"` | |
| 30 | - | GeneratedAt string `json:"generated_at"` | |
| 31 | - | SessionID string `json:"session_id"` | |
| 32 | - | Classification SessionExitClassification `json:"classification"` | |
| 33 | - | StartupPID int `json:"startup_pid,omitempty"` | |
| 34 | - | StartupPPID int `json:"startup_ppid,omitempty"` | |
| 35 | - | MarkerFiles map[string]string `json:"marker_files"` | |
| 36 | - | SessionLogFile string `json:"session_log_file,omitempty"` | |
| 37 | - | SessionLogTail []string `json:"session_log_tail_redacted,omitempty"` | |
| 38 | - | ReporterProcess unexpectedCloseReporter `json:"reporter_process"` | |
| 39 | - | CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"` | |
| 40 | - | IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"` | |
| 41 | - | GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"` | |
| 27 | + | type crashReport struct { | |
| 28 | + | ReportVersion int `json:"report_version"` | |
| 29 | + | GeneratedAt string `json:"generated_at"` | |
| 30 | + | SessionID string `json:"session_id"` | |
| 31 | + | CrashedPID int `json:"crashed_pid,omitempty"` | |
| 32 | + | SessionLogFile string `json:"session_log_file,omitempty"` | |
| 33 | + | SessionLogTail []string `json:"session_log_tail_redacted,omitempty"` | |
| 34 | + | ReporterProcess crashReporter `json:"reporter_process"` | |
| 35 | + | CoreDumpDiagnostics crashCoreDump `json:"core_dump_diagnostics"` | |
| 36 | + | IssueTemplate crashIssueTemplate `json:"issue_template"` | |
| 37 | + | GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"` | |
| 42 | 38 | } | |
| 43 | 39 | ||
| 44 | - | type unexpectedCloseReporter struct { | |
| 40 | + | type crashReporter struct { | |
| 45 | 41 | GeneratedBy string `json:"generated_by"` | |
| 46 | 42 | GoVersion string `json:"go_version"` | |
| 47 | 43 | GOOS string `json:"goos"` | |
| @@ -50,33 +46,26 @@ type unexpectedCloseReporter struct { | |||
| 50 | 46 | PPID int `json:"ppid"` | |
| 51 | 47 | } | |
| 52 | 48 | ||
| 53 | - | type unexpectedCloseCoreDump struct { | |
| 49 | + | type crashCoreDump struct { | |
| 54 | 50 | RLimitCoreSoft string `json:"rlimit_core_soft"` | |
| 55 | 51 | RLimitCoreHard string `json:"rlimit_core_hard"` | |
| 56 | 52 | Hint string `json:"hint"` | |
| 57 | 53 | } | |
| 58 | 54 | ||
| 59 | - | type unexpectedCloseIssueTemplate struct { | |
| 55 | + | type crashIssueTemplate struct { | |
| 60 | 56 | Title string `json:"title"` | |
| 61 | 57 | Summary string `json:"summary"` | |
| 62 | 58 | } | |
| 63 | 59 | ||
| 64 | - | func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |
| 65 | - | if lockDir == "" || sessionID == "" { | |
| 66 | - | return "", errors.New("lockDir and sessionID are required") | |
| 60 | + | // writeCrashReport generates JSON + Markdown crash reports for a crashed session. | |
| 61 | + | // Returns the JSON report path, or empty string if the report already exists. | |
| 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) | |
| 70 | - | if classifyErr != 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 { | |
| 67 | + | reportsDir := filepath.Join(logDir, crashReportsDirName) | |
| 68 | + | if err := os.MkdirAll(reportsDir, dirPerm); err != nil { | |
| 80 | 69 | return "", err | |
| 81 | 70 | } | |
| 82 | 71 | ||
| @@ -84,47 +73,34 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |||
| 84 | 73 | ||
| 85 | 74 | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | |
| 86 | 75 | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | |
| 87 | - | if _, statErr := os.Stat(jsonPath); statErr == nil { | |
| 88 | - | if _, mdErr := os.Stat(markdownPath); mdErr == nil { | |
| 89 | - | return jsonPath, 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 | - | } | |
| 76 | + | ||
| 77 | + | // Skip if report already exists. | |
| 78 | + | if _, err := os.Stat(jsonPath); err == nil { | |
| 103 | 79 | return jsonPath, nil | |
| 104 | - | } else if !os.IsNotExist(statErr) { | |
| 105 | - | return "", statErr | |
| 106 | 80 | } | |
| 107 | 81 | ||
| 108 | - | report := unexpectedCloseReport{ | |
| 109 | - | ReportVersion: 1, | |
| 110 | - | GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), | |
| 111 | - | SessionID: sessionID, | |
| 112 | - | Classification: classification, | |
| 113 | - | MarkerFiles: reportMarkerFiles(lockDir, sessionID), | |
| 114 | - | ReporterProcess: currentReporter(), | |
| 82 | + | report := crashReport{ | |
| 83 | + | ReportVersion: 2, | |
| 84 | + | GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), | |
| 85 | + | SessionID: sessionID, | |
| 86 | + | CrashedPID: crashedPID, | |
| 87 | + | ReporterProcess: crashReporter{ | |
| 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 | 95 | CoreDumpDiagnostics: collectCoreDumpDiagnostics(), | |
| 116 | - | IssueTemplate: unexpectedCloseIssueTemplate{ | |
| 96 | + | IssueTemplate: crashIssueTemplate{ | |
| 117 | 97 | Title: fmt.Sprintf("Unexpected close in session %s", sessionID), | |
| 118 | 98 | Summary: "Dumber closed unexpectedly and generated this report automatically.", | |
| 119 | 99 | }, | |
| 120 | 100 | } | |
| 121 | 101 | ||
| 122 | - | startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID) | |
| 123 | - | report.StartupPID = startupPID | |
| 124 | - | report.StartupPPID = startupPPID | |
| 125 | - | ||
| 126 | - | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |
| 127 | - | if _, statErr := os.Stat(logPath); statErr == nil { | |
| 102 | + | logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID)) | |
| 103 | + | if _, err := os.Stat(logPath); err == nil { | |
| 128 | 104 | report.SessionLogFile = logPath | |
| 129 | 105 | report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount) | |
| 130 | 106 | } | |
| @@ -136,113 +112,35 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |||
| 136 | 112 | return "", err | |
| 137 | 113 | } | |
| 138 | 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 | 116 | return "", err | |
| 141 | 117 | } | |
| 142 | 118 | ||
| 143 | - | markdown := buildUnexpectedCloseMarkdown(report) | |
| 144 | - | if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil { | |
| 119 | + | markdown := buildCrashMarkdown(report) | |
| 120 | + | if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil { | |
| 145 | 121 | return "", err | |
| 146 | 122 | } | |
| 147 | 123 | ||
| 148 | 124 | return jsonPath, nil | |
| 149 | 125 | } | |
| 150 | 126 | ||
| 151 | - | func reportMarkerFiles(lockDir, sessionID string) map[string]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 { | |
| 127 | + | func buildCrashMarkdown(report crashReport) string { | |
| 222 | 128 | lines := []string{ | |
| 223 | 129 | "# Unexpected Close Report", | |
| 224 | 130 | "", | |
| 225 | 131 | fmt.Sprintf("Generated: `%s`", report.GeneratedAt), | |
| 226 | 132 | fmt.Sprintf("Session: `%s`", report.SessionID), | |
| 227 | - | fmt.Sprintf("Class: `%s`", report.Classification.Class), | |
| 228 | - | fmt.Sprintf("Inference: `%s`", report.Classification.Inference), | |
| 229 | - | fmt.Sprintf("Reason: `%s`", report.Classification.Reason), | |
| 133 | + | fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID), | |
| 230 | 134 | "", | |
| 231 | - | "## Process Context", | |
| 232 | - | fmt.Sprintf("- startup pid: `%d`", report.StartupPID), | |
| 233 | - | fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID), | |
| 234 | - | fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID), | |
| 235 | - | fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID), | |
| 135 | + | "## Reporter", | |
| 136 | + | fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID), | |
| 137 | + | fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID), | |
| 138 | + | fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion), | |
| 236 | 139 | "", | |
| 237 | 140 | "## Core Dump Diagnostics", | |
| 238 | 141 | fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft), | |
| 239 | 142 | fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard), | |
| 240 | 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 | 146 | if len(report.SessionLogTail) > 0 { | |
| @@ -266,8 +164,7 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string { | |||
| 266 | 164 | "", | |
| 267 | 165 | "### Crash report", | |
| 268 | 166 | fmt.Sprintf("- session id: `%s`", report.SessionID), | |
| 269 | - | fmt.Sprintf("- class: `%s`", report.Classification.Class), | |
| 270 | - | fmt.Sprintf("- reason: `%s`", report.Classification.Reason), | |
| 167 | + | fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID), | |
| 271 | 168 | "", | |
| 272 | 169 | "### Additional context", | |
| 273 | 170 | "- distro:", | |
| @@ -279,6 +176,47 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string { | |||
| 279 | 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 | 220 | var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`) | |
| 283 | 221 | ||
| 284 | 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 | 3 | package bootstrap | |
| 4 | 4 | ||
| 5 | - | func collectCoreDumpDiagnostics() unexpectedCloseCoreDump { | |
| 6 | - | return unexpectedCloseCoreDump{ | |
| 5 | + | func collectCoreDumpDiagnostics() crashCoreDump { | |
| 6 | + | return crashCoreDump{ | |
| 7 | 7 | RLimitCoreSoft: "unsupported", | |
| 8 | 8 | RLimitCoreHard: "unsupported", | |
| 9 | 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 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | 9 | "testing" | |
| 10 | - | "time" | |
| 11 | 10 | ||
| 12 | 11 | corelogging "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | "github.com/stretchr/testify/assert" | |
| @@ -65,7 +64,7 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) { | |||
| 65 | 64 | for i := 0; i < 10; i++ { | |
| 66 | 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 | 69 | t.Run("fewer_lines_than_requested", func(t *testing.T) { | |
| 71 | 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) { | |
| 87 | - | lockDir := t.TempDir() | |
| 85 | + | func TestWriteCrashReport(t *testing.T) { | |
| 86 | + | logDir := t.TempDir() | |
| 88 | 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)) | |
| 92 | - | _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil) | |
| 93 | - | require.NoError(t, err) | |
| 94 | - | ||
| 95 | - | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |
| 89 | + | logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID)) | |
| 96 | 90 | logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` + | |
| 97 | 91 | "\n" + | |
| 98 | 92 | `{"level":"warn","message":"callback code=abc token=def"}` + | |
| 99 | 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 | 97 | require.NoError(t, err) | |
| 104 | 98 | require.NotEmpty(t, jsonPath) | |
| 105 | 99 | assert.FileExists(t, jsonPath) | |
| @@ -113,20 +107,15 @@ func TestWriteUnexpectedCloseReport(t *testing.T) { | |||
| 113 | 107 | var payload map[string]any | |
| 114 | 108 | require.NoError(t, json.Unmarshal(raw, &payload)) | |
| 115 | 109 | assert.Equal(t, sessionID, payload["session_id"]) | |
| 116 | - | ||
| 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) | |
| 110 | + | assert.InDelta(t, float64(12345), payload["crashed_pid"], 0) | |
| 122 | 111 | ||
| 123 | 112 | tail, ok := payload["session_log_tail_redacted"].([]any) | |
| 124 | 113 | require.True(t, ok, "session_log_tail_redacted should be a []any") | |
| 125 | 114 | require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries") | |
| 126 | 115 | first, ok := tail[0].(string) | |
| 127 | - | require.True(t, ok, "first tail entry should be a string") | |
| 116 | + | require.True(t, ok) | |
| 128 | 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 | 119 | text := first + last | |
| 131 | 120 | assert.NotContains(t, text, "?a=1&b=2") | |
| 132 | 121 | assert.NotContains(t, text, "code=abc") | |
M internal/bootstrap/crash_report_unix.go +6 -4
| @@ -8,14 +8,16 @@ import ( | |||
| 8 | 8 | "golang.org/x/sys/unix" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func collectCoreDumpDiagnostics() unexpectedCloseCoreDump { | |
| 12 | - | d := unexpectedCloseCoreDump{ | |
| 11 | + | const unknownDiag = "unknown" | |
| 12 | + | ||
| 13 | + | func collectCoreDumpDiagnostics() crashCoreDump { | |
| 14 | + | d := crashCoreDump{ | |
| 13 | 15 | Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.", | |
| 14 | 16 | } | |
| 15 | 17 | var limit unix.Rlimit | |
| 16 | 18 | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |
| 17 | - | d.RLimitCoreSoft = string(SessionExitUnknown) | |
| 18 | - | d.RLimitCoreHard = string(SessionExitUnknown) | |
| 19 | + | d.RLimitCoreSoft = unknownDiag | |
| 20 | + | d.RLimitCoreHard = unknownDiag | |
| 19 | 21 | return d | |
| 20 | 22 | } | |
| 21 | 23 | d.RLimitCoreSoft = formatRlimitCore(limit.Cur) | |
M internal/bootstrap/engine.go +57 -63
| @@ -2,13 +2,17 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | ||
| 7 | 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 | 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 | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | |
| 12 | 16 | "github.com/bnema/dumber/internal/ui/theme" | |
| 13 | 17 | "github.com/rs/zerolog" | |
| 14 | 18 | ) | |
| @@ -25,15 +29,11 @@ type EngineInput struct { | |||
| 25 | 29 | } | |
| 26 | 30 | ||
| 27 | 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 | 32 | func BuildEngine(input EngineInput) (port.Engine, error) { | |
| 30 | 33 | cfg := input.Config | |
| 31 | - | engineType := cfg.Engine.Type | |
| 32 | - | if engineType == "" { | |
| 33 | - | engineType = "webkit" | |
| 34 | - | } | |
| 34 | + | engineType := cfg.Engine.ResolveEngineType() | |
| 35 | 35 | switch engineType { | |
| 36 | - | case "webkit": | |
| 36 | + | case config.EngineTypeWebKit: | |
| 37 | 37 | opts := port.EngineOptions{ | |
| 38 | 38 | DataDir: input.DataDir, | |
| 39 | 39 | CacheDir: input.CacheDir, | |
| @@ -41,66 +41,60 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 41 | 41 | } | |
| 42 | 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 | 44 | return webkit.NewEngine( | |
| 57 | 45 | input.Ctx, cfg, opts, wkCfg, | |
| 58 | 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": | |
| 73 | - | return nil, fmt.Errorf("CEF engine not yet implemented") | |
| 48 | + | case config.EngineTypeCEF: | |
| 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 | 97 | default: | |
| 75 | 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 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| @@ -16,10 +17,28 @@ func TestBuildEngine_UnknownType(t *testing.T) { | |||
| 16 | 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 | 21 | cfg := config.DefaultConfig() | |
| 21 | 22 | cfg.Engine.Type = "cef" | |
| 23 | + | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 22 | 24 | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 23 | 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 | 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 | 152 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | |
| 153 | 153 | var ( | |
| 154 | 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 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "syscall" | |
| @@ -23,41 +24,48 @@ import ( | |||
| 23 | 24 | ||
| 24 | 25 | const ( | |
| 25 | 26 | recentSessionsLimit = 200 | |
| 26 | - | lockDirPerm = 0o755 | |
| 27 | - | lockFilePerm = 0o600 | |
| 28 | - | markerFilePerm = 0o644 | |
| 27 | + | dirPerm = 0o755 | |
| 28 | + | filePerm = 0o644 | |
| 29 | + | pidFileName = "dumber.pid" | |
| 29 | 30 | ) | |
| 30 | 31 | ||
| 32 | + | // BrowserSession holds the state for a running browser session. | |
| 31 | 33 | type BrowserSession struct { | |
| 32 | 34 | Session *entity.Session | |
| 33 | 35 | Logger zerolog.Logger | |
| 34 | 36 | LogCleanup func() | |
| 37 | + | LogPath string | |
| 35 | 38 | ||
| 36 | - | reportsMu sync.RWMutex | |
| 37 | - | unexpectedCloseReportPath []string | |
| 39 | + | reportsMu sync.RWMutex | |
| 40 | + | crashReportPaths []string | |
| 38 | 41 | ||
| 39 | 42 | Persist func(context.Context) error | |
| 40 | 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 | 48 | if s == nil { | |
| 45 | 49 | return | |
| 46 | 50 | } | |
| 47 | 51 | s.reportsMu.Lock() | |
| 48 | 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 | 58 | if s == nil { | |
| 54 | 59 | return nil | |
| 55 | 60 | } | |
| 56 | 61 | s.reportsMu.RLock() | |
| 57 | 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 | 69 | func StartBrowserSession( | |
| 62 | 70 | ctx context.Context, | |
| 63 | 71 | cfg *config.Config, | |
| @@ -69,10 +77,10 @@ func StartBrowserSession( | |||
| 69 | 77 | return nil, ctx, errors.New("session repository is nil") | |
| 70 | 78 | } | |
| 71 | 79 | ||
| 72 | - | lockDir := cfg.Logging.LogDir | |
| 73 | - | if lockDir == "" { | |
| 80 | + | logDir := cfg.Logging.LogDir | |
| 81 | + | if logDir == "" { | |
| 74 | 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 | 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 ( | |
| 108 | - | lockMu sync.Mutex | |
| 109 | - | lockFile *os.File | |
| 110 | - | lockPath string | |
| 111 | - | persistMu sync.Once | |
| 112 | - | persistErr error | |
| 113 | - | ) | |
| 121 | + | sessionCtx := corelogging.WithContext(context.Background(), logger) | |
| 114 | 122 | ||
| 115 | - | browserSession := &BrowserSession{ | |
| 123 | + | bs := &BrowserSession{ | |
| 116 | 124 | Session: session, | |
| 117 | 125 | Logger: logger, | |
| 118 | 126 | LogCleanup: cleanup, | |
| 127 | + | LogPath: logPath, | |
| 119 | 128 | } | |
| 120 | 129 | ||
| 121 | - | // persistFn ensures the session is saved exactly once. Using sync.Once is intentional: | |
| 122 | - | // if the first persist fails, we cache the error and return it on subsequent calls | |
| 123 | - | // rather than retrying. This prevents duplicate session records and ensures consistent | |
| 124 | - | // error handling during shutdown sequences where persistFn may be called multiple times. | |
| 130 | + | var persistOnce sync.Once | |
| 131 | + | var persistErr error | |
| 132 | + | ||
| 125 | 133 | persistFn := func(persistCtx context.Context) error { | |
| 126 | - | persistMu.Do(func() { | |
| 127 | - | if lockDir != "" { | |
| 128 | - | generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger) | |
| 129 | - | if abruptErr != nil { | |
| 130 | - | logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers") | |
| 131 | - | } else if len(generatedReports) > 0 { | |
| 132 | - | browserSession.SetUnexpectedCloseReports(generatedReports) | |
| 134 | + | persistOnce.Do(func() { | |
| 135 | + | // Check for crash from previous run. | |
| 136 | + | if logDir != "" { | |
| 137 | + | reports := detectPreviousCrash(logDir, &logger) | |
| 138 | + | if len(reports) > 0 { | |
| 139 | + | bs.SetCrashReports(reports) | |
| 133 | 140 | } | |
| 134 | 141 | } | |
| 135 | 142 | ||
| @@ -141,28 +148,15 @@ func StartBrowserSession( | |||
| 141 | 148 | ||
| 142 | 149 | logger.Info(). | |
| 143 | 150 | Str("session_id", string(session.ID)). | |
| 144 | - | Str("type", string(session.Type)). | |
| 145 | 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 | - | if lockErr != nil { | |
| 154 | - | // Don’t fail startup if locking fails; it only affects stale-session cleanup. | |
| 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() | |
| 153 | + | // Write PID file so next startup can detect crashes. | |
| 154 | + | if logDir != "" { | |
| 155 | + | writePIDFile(logDir, &logger) | |
| 161 | 156 | } | |
| 162 | 157 | ||
| 163 | - | // Defer stale session cleanup and old session pruning to background. | |
| 164 | - | // This avoids blocking startup for non-critical housekeeping tasks. | |
| 165 | - | runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log) | |
| 158 | + | // Background cleanup of old sessions. | |
| 159 | + | go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log) | |
| 166 | 160 | }) | |
| 167 | 161 | return persistErr | |
| 168 | 162 | } | |
| @@ -171,29 +165,17 @@ func StartBrowserSession( | |||
| 171 | 165 | _ = persistFn(endCtx) | |
| 172 | 166 | endErr := sessionUC.EndSession(endCtx, session.ID, time.Now()) | |
| 173 | 167 | ||
| 174 | - | lockMu.Lock() | |
| 175 | - | lf := lockFile | |
| 176 | - | lp := lockPath | |
| 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) | |
| 168 | + | // Remove PID file on clean shutdown. | |
| 169 | + | if logDir != "" { | |
| 170 | + | removePIDFile(logDir) | |
| 189 | 171 | } | |
| 190 | 172 | return endErr | |
| 191 | 173 | } | |
| 192 | 174 | ||
| 193 | - | browserSession.End = endFn | |
| 175 | + | bs.End = endFn | |
| 194 | 176 | ||
| 195 | 177 | if deferPersist { | |
| 196 | - | browserSession.Persist = persistFn | |
| 178 | + | bs.Persist = persistFn | |
| 197 | 179 | } else { | |
| 198 | 180 | if err := persistFn(ctx); err != nil { | |
| 199 | 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) { | |
| 210 | - | abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger) | |
| 211 | - | if abruptErr != nil { | |
| 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 | - | } | |
| 191 | + | // --------------------------------------------------------------------------- | |
| 192 | + | // PID file: single file {logDir}/dumber.pid | |
| 193 | + | // --------------------------------------------------------------------------- | |
| 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( | |
| 248 | - | ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger, | |
| 249 | - | ) error { | |
| 250 | - | // We only have GetRecentSessions; fetch enough to cover likely actives. | |
| 251 | - | recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit) | |
| 252 | - | if err != nil { | |
| 253 | - | return err | |
| 199 | + | func writePIDFile(logDir string, logger *zerolog.Logger) { | |
| 200 | + | p := pidFilePath(logDir) | |
| 201 | + | content := fmt.Sprintf("%d\n", os.Getpid()) | |
| 202 | + | if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil { | |
| 203 | + | logger.Warn().Err(err).Msg("failed to write PID file") | |
| 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 { | |
| 298 | - | return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID)) | |
| 207 | + | func removePIDFile(logDir string) { | |
| 208 | + | _ = os.Remove(pidFilePath(logDir)) | |
| 299 | 209 | } | |
| 300 | 210 | ||
| 301 | - | func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) { | |
| 302 | - | if lockDir == "" { | |
| 303 | - | return nil, "", errors.New("lock dir is empty") | |
| 304 | - | } | |
| 305 | - | if err := os.MkdirAll(lockDir, lockDirPerm); err != nil { | |
| 306 | - | return nil, "", err | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | lockPath := sessionLockPath(lockDir, sessionID) | |
| 310 | - | f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm) | |
| 211 | + | // detectPreviousCrash checks the PID file from a previous run. | |
| 212 | + | // If the file exists and the process is dead, the previous session crashed. | |
| 213 | + | // Returns generated crash report paths (if any). | |
| 214 | + | func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string { | |
| 215 | + | p := pidFilePath(logDir) | |
| 216 | + | raw, err := os.ReadFile(p) | |
| 311 | 217 | if err != nil { | |
| 312 | - | return nil, "", err | |
| 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") | |
| 218 | + | return nil // No PID file → clean previous exit (or first run). | |
| 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 { | |
| 342 | - | if f == nil { | |
| 221 | + | pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw))) | |
| 222 | + | if parseErr != nil { | |
| 223 | + | // Corrupt PID file, remove and move on. | |
| 224 | + | _ = os.Remove(p) | |
| 343 | 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) { | |
| 420 | - | if lockDir == "" { | |
| 421 | - | return nil, nil | |
| 422 | - | } | |
| 423 | - | pattern := filepath.Join(lockDir, "session_*.startup.marker") | |
| 424 | - | startupMarkers, err := filepath.Glob(pattern) | |
| 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 { | |
| 228 | + | // Check if the process is still running. | |
| 229 | + | proc, procErr := os.FindProcess(pid) | |
| 230 | + | if procErr == nil { | |
| 231 | + | // On Unix, FindProcess always succeeds. Send signal 0 to probe existence. | |
| 232 | + | if killErr := proc.Signal(syscall.Signal(0)); killErr == nil { | |
| 233 | + | // Process is alive → another instance running, don't touch PID file. | |
| 438 | 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 | |
| 442 | - | } | |
| 443 | - | if !shouldMark { | |
| 444 | - | continue | |
| 237 | + | return nil | |
| 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 | |
| 466 | - | } | |
| 241 | + | // Previous process is dead → crash. Generate report from last log. | |
| 242 | + | _ = os.Remove(p) | |
| 467 | 243 | ||
| 468 | - | func sessionIDFromStartupMarker(startupPath string) string { | |
| 469 | - | base := filepath.Base(startupPath) | |
| 470 | - | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") { | |
| 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 | |
| 244 | + | lastLog := findLastSessionLog(logDir) | |
| 245 | + | if lastLog == "" { | |
| 246 | + | return nil | |
| 483 | 247 | } | |
| 484 | 248 | ||
| 485 | - | shutdownPath := shutdownMarkerPath(lockDir, sessionID) | |
| 486 | - | if exists, statErr := markerExists(shutdownPath); statErr != nil { | |
| 487 | - | return false, statErr | |
| 488 | - | } else if exists { | |
| 489 | - | return false, nil | |
| 249 | + | lastSessionID := sessionIDFromLogPath(lastLog) | |
| 250 | + | if lastSessionID == "" { | |
| 251 | + | return nil | |
| 490 | 252 | } | |
| 491 | 253 | ||
| 492 | - | abruptPath := abruptMarkerPath(lockDir, sessionID) | |
| 493 | - | if exists, statErr := markerExists(abruptPath); statErr != nil { | |
| 494 | - | return false, statErr | |
| 495 | - | } else if exists { | |
| 496 | - | return false, nil | |
| 254 | + | if logger != nil { | |
| 255 | + | logger.Warn(). | |
| 256 | + | Int("crashed_pid", pid). | |
| 257 | + | Str("session_id", lastSessionID). | |
| 258 | + | Msg("previous session crashed (stale PID file)") | |
| 497 | 259 | } | |
| 498 | 260 | ||
| 499 | - | return true, nil | |
| 500 | - | } | |
| 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 | - | } | |
| 261 | + | reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid) | |
| 262 | + | if reportErr != nil { | |
| 509 | 263 | if logger != nil { | |
| 510 | - | logger.Warn(). | |
| 511 | - | Err(openErr). | |
| 512 | - | Str("session_id", sessionID). | |
| 513 | - | Str("path", lockPath). | |
| 514 | - | Msg("markAbruptExits: open lock file failed") | |
| 264 | + | logger.Warn().Err(reportErr).Msg("failed to write crash report") | |
| 515 | 265 | } | |
| 516 | - | return false, openErr | |
| 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 | |
| 266 | + | return nil | |
| 530 | 267 | } | |
| 531 | - | if !locked { | |
| 532 | - | _ = f.Close() | |
| 533 | - | return true, nil | |
| 268 | + | if reportPath == "" { | |
| 269 | + | return nil | |
| 534 | 270 | } | |
| 535 | - | _ = unlockAndClose(f) | |
| 536 | - | return false, nil | |
| 271 | + | return []string{reportPath} | |
| 537 | 272 | } | |
| 538 | 273 | ||
| 539 | - | func markerExists(path string) (bool, error) { | |
| 540 | - | _, err := os.Stat(path) | |
| 541 | - | if err == nil { | |
| 542 | - | return true, nil | |
| 274 | + | // findLastSessionLog returns the most recent session_*.log file in logDir. | |
| 275 | + | func findLastSessionLog(logDir string) string { | |
| 276 | + | matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log")) | |
| 277 | + | if err != nil || len(matches) == 0 { | |
| 278 | + | return "" | |
| 543 | 279 | } | |
| 544 | - | if os.IsNotExist(err) { | |
| 545 | - | return false, nil | |
| 280 | + | var newest string | |
| 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 { | |
| 551 | - | trimmed := strings.TrimSpace(string(raw)) | |
| 552 | - | if trimmed == "" { | |
| 295 | + | func sessionIDFromLogPath(logPath string) string { | |
| 296 | + | base := filepath.Base(logPath) | |
| 297 | + | id, ok := corelogging.ParseSessionFilename(base) | |
| 298 | + | if !ok { | |
| 553 | 299 | return "" | |
| 554 | 300 | } | |
| 555 | - | first, _, _ := strings.Cut(trimmed, "\n") | |
| 556 | - | return strings.TrimSpace(first) | |
| 301 | + | return id | |
| 557 | 302 | } | |
| 558 | 303 | ||
| 559 | - | func markerValue(raw []byte, key string) string { | |
| 560 | - | if key == "" || len(raw) == 0 { | |
| 561 | - | return "" | |
| 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 | - | } | |
| 304 | + | // --------------------------------------------------------------------------- | |
| 305 | + | // Background cleanup | |
| 306 | + | // --------------------------------------------------------------------------- | |
| 571 | 307 | ||
| 572 | - | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 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( | |
| 308 | + | func runSessionCleanup( | |
| 577 | 309 | startupCtx context.Context, | |
| 578 | 310 | sessionUC *usecase.ManageSessionUseCase, | |
| 579 | 311 | cleanupUC *usecase.CleanupSessionsUseCase, | |
| 580 | 312 | cfg *config.Config, | |
| 581 | - | lockDir string, | |
| 313 | + | currentSessionID entity.SessionID, | |
| 582 | 314 | log *zerolog.Logger, | |
| 583 | 315 | ) { | |
| 584 | - | go func() { | |
| 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 | - | } | |
| 316 | + | bgCtx := context.WithoutCancel(startupCtx) | |
| 597 | 317 | ||
| 598 | - | // Clean up old exited sessions based on config limits | |
| 599 | - | cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{ | |
| 600 | - | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |
| 601 | - | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |
| 602 | - | }) | |
| 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 | - | } | |
| 318 | + | // End stale active sessions that have no running process. | |
| 319 | + | recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit) | |
| 320 | + | if err != nil { | |
| 321 | + | if log != nil { | |
| 322 | + | log.Warn().Err(err).Msg("background: failed to list recent sessions") | |
| 611 | 323 | } | |
| 612 | - | ||
| 613 | - | // Sweep paired startup+shutdown markers that are no longer needed. | |
| 614 | - | if lockDir != "" { | |
| 615 | - | sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log) | |
| 324 | + | } else { | |
| 325 | + | now := time.Now() | |
| 326 | + | for _, s := range recent { | |
| 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 | 341 | if err != nil { | |
| 636 | 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 | |
| 643 | - | for _, shutdownPath := range shutdownMarkers { | |
| 644 | - | base := filepath.Base(shutdownPath) | |
| 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) | |
| 349 | + | // Prune old marker files left by previous versions. | |
| 350 | + | sweepLegacyMarkers(cfg.Logging.LogDir, log) | |
| 351 | + | } | |
| 666 | 352 | ||
| 667 | - | // Remove the trio: shutdown, startup, abrupt (if present). | |
| 668 | - | for _, p := range []string{shutdownPath, startupPath, abruptPath} { | |
| 669 | - | if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) { | |
| 670 | - | if log != nil { | |
| 671 | - | log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed") | |
| 672 | - | } | |
| 353 | + | // sweepLegacyMarkers removes .startup.marker, .shutdown.marker, | |
| 354 | + | // .abrupt.marker, and .lock files left by the old session system. | |
| 355 | + | func sweepLegacyMarkers(logDir string, log *zerolog.Logger) { | |
| 356 | + | if logDir == "" { | |
| 357 | + | return | |
| 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 | 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 | 113 | // Create use cases | |
| 114 | 114 | searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 115 | - | listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir) | |
| 115 | + | listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo) | |
| 116 | 116 | restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo) | |
| 117 | - | deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir) | |
| 117 | + | deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo) | |
| 118 | 118 | ||
| 119 | 119 | // Create favicon service for CLI (path resolution for dmenu/fuzzel) | |
| 120 | 120 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
M internal/domain/service/favicon.go +2 -2
| @@ -12,10 +12,10 @@ type FaviconService interface { | |||
| 12 | 12 | ||
| 13 | 13 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 14 | 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 | 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 | 20 | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | |
| 21 | 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 | 67 | // Performance defaults | |
| 68 | 68 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 69 | 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 | 78 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 72 | 79 | defaultSkiaCPUPaintingThreads = 0 | |
| @@ -151,6 +158,11 @@ func DefaultConfig() *Config { | |||
| 151 | 158 | // third-party cookie isolation more intelligently. Using Always + ITP | |
| 152 | 159 | // matches Epiphany's model and avoids a misleading setting. | |
| 153 | 160 | CookiePolicy: CookiePolicyAlways, | |
| 161 | + | CEF: CEFEngineConfig{ | |
| 162 | + | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 163 | + | LogFile: "", | |
| 164 | + | EnableAudioHandler: true, | |
| 165 | + | }, | |
| 154 | 166 | WebKit: WebKitEngineConfig{ | |
| 155 | 167 | ITPEnabled: true, | |
| 156 | 168 | SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads, | |
| @@ -305,6 +317,12 @@ func DefaultConfig() *Config { | |||
| 305 | 317 | Downloads: DownloadsConfig{ | |
| 306 | 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 | 11 | assert.Equal(t, "info", cfg.Logging.Level) | |
| 12 | 12 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 13 | 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 | 19 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 16 | 20 | assert.Equal(t, "webkit", cfg.Engine.Type) | |
| 17 | 21 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | |
| 18 | 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 | 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
| 20 | 29 | ||
| 21 | 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 | 1 | package config | |
| 2 | 2 | ||
| 3 | + | import "os" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | EngineTypeWebKit = "webkit" | |
| 7 | + | EngineTypeCEF = "cef" | |
| 8 | + | ) | |
| 9 | + | ||
| 3 | 10 | // EngineConfig holds engine selection and universal engine options. | |
| 4 | 11 | type EngineConfig struct { | |
| 5 | 12 | Type string `mapstructure:"type" toml:"type" yaml:"type"` | |
| @@ -8,6 +15,21 @@ type EngineConfig struct { | |||
| 8 | 15 | Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"` | |
| 9 | 16 | CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"` | |
| 10 | 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 | 35 | // WebKitEngineConfig holds WebKit-specific engine options. | |
| @@ -57,6 +79,38 @@ type WebKitEngineConfig struct { | |||
| 57 | 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 | 114 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | |
| 61 | 115 | // new [engine] config section, for use with ResolvePerformanceProfile. | |
| 62 | 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 | 414 | m.setDebugDefaults(defaults) | |
| 415 | 415 | m.setAppearanceDefaults(defaults) | |
| 416 | 416 | m.setEngineDefaults(defaults) | |
| 417 | + | m.setTranscodingDefaults(defaults) | |
| 417 | 418 | m.setZoomAndScaleDefaults(defaults) | |
| 418 | 419 | m.setWorkspaceDefaults(defaults) | |
| 419 | 420 | m.setContentFilteringDefaults(defaults) | |
| @@ -568,6 +569,15 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 568 | 569 | m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize) | |
| 569 | 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 | 581 | wk := e.WebKit | |
| 572 | 582 | m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled) | |
| 573 | 583 | m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads) | |
| @@ -597,6 +607,14 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 597 | 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 | 618 | // New returns a new default configuration instance. | |
| 601 | 619 | // This is a convenience function for getting default config without the full manager. | |
| 602 | 620 | func New() *Config { | |
M internal/infrastructure/config/loader_test.go +40 -0
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/spf13/viper" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestSetEngineDefaults(t *testing.T) { | |
| @@ -24,6 +27,12 @@ func TestSetEngineDefaults(t *testing.T) { | |||
| 24 | 27 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | |
| 25 | 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 | 36 | // Old sections should NOT be registered as defaults | |
| 28 | 37 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | |
| 29 | 38 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | |
| @@ -39,3 +48,34 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |||
| 39 | 48 | ||
| 40 | 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 | 98 | Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"` | |
| 99 | 99 | // Engine holds engine selection and unified engine options. | |
| 100 | 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 | 105 | // CookiePolicy controls cookie acceptance behavior. | |
| @@ -362,3 +364,20 @@ type DownloadsConfig struct { | |||
| 362 | 364 | // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads. | |
| 363 | 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 | 847 | Description: "Enable browser developer tools (F12)", | |
| 848 | 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 | 911 | Range: ">=0", | |
| 877 | 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 | 923 | Key: "engine.webkit.skia_cpu_painting_threads", | |
| 881 | 924 | Type: "int", | |
| @@ -968,6 +1011,13 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 968 | 1011 | ||
| 969 | 1012 | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 970 | 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 | 1022 | Key: "engine.webkit.prefix", | |
| 973 | 1023 | Type: "string", | |
M internal/infrastructure/config/validation.go +29 -0
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | const cefLogSeverityDisabled = 99 | |
| 14 | + | ||
| 13 | 15 | // validateConfig performs comprehensive validation of configuration values | |
| 14 | 16 | func validateConfig(config *Config) error { | |
| 15 | 17 | var validationErrors []string | |
| @@ -32,6 +34,7 @@ func validateConfig(config *Config) error { | |||
| 32 | 34 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 33 | 35 | validationErrors = append(validationErrors, validateSession(config)...) | |
| 34 | 36 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| 37 | + | validationErrors = append(validationErrors, validateCEF(config)...) | |
| 35 | 38 | ||
| 36 | 39 | // If there are validation errors, return them | |
| 37 | 40 | if len(validationErrors) > 0 { | |
| @@ -324,6 +327,26 @@ func validateLogging(config *Config) []string { | |||
| 324 | 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 | 350 | func validateWorkspaceNewPaneURL(config *Config) []string { | |
| 328 | 351 | var validationErrors []string | |
| 329 | 352 | ||
| @@ -441,6 +464,12 @@ func validateSession(config *Config) []string { | |||
| 441 | 464 | func validatePerformanceProfile(config *Config) []string { | |
| 442 | 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 | 473 | // Validate profile name | |
| 445 | 474 | if !IsValidPerformanceProfile(config.Engine.Profile) { | |
| 446 | 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 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 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 | 68 | t.Logf("Found %d sections, all sorted", len(sections)) | |
| 47 | 69 | ||
| 48 | 70 | // Print first 50 lines for debugging | |
M internal/infrastructure/favicon/cache.go +18 -2
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package favicon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | const ( | |
| @@ -51,16 +53,21 @@ func NewCache(diskDir string) *Cache { | |||
| 51 | 53 | // Get retrieves favicon bytes for a domain. | |
| 52 | 54 | // Checks memory cache first, then disk cache. | |
| 53 | 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 | 57 | if domain == "" { | |
| 56 | 58 | return nil, false | |
| 57 | 59 | } | |
| 60 | + | log := logging.FromContext(ctx) | |
| 58 | 61 | ||
| 59 | 62 | // Check memory cache first | |
| 60 | 63 | c.mu.RLock() | |
| 61 | 64 | data, ok := c.memCache[domain] | |
| 62 | 65 | c.mu.RUnlock() | |
| 63 | 66 | if ok { | |
| 67 | + | log.Debug(). | |
| 68 | + | Str("domain", domain). | |
| 69 | + | Int("bytes", len(data)). | |
| 70 | + | Msg("favicon: Cache.Get memory hit") | |
| 64 | 71 | return data, true | |
| 65 | 72 | } | |
| 66 | 73 | ||
| @@ -71,18 +78,27 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||
| 71 | 78 | c.mu.Lock() | |
| 72 | 79 | c.memCache[domain] = data | |
| 73 | 80 | c.mu.Unlock() | |
| 81 | + | log.Debug(). | |
| 82 | + | Str("domain", domain). | |
| 83 | + | Int("bytes", len(data)). | |
| 84 | + | Msg("favicon: Cache.Get disk hit") | |
| 74 | 85 | return data, true | |
| 75 | 86 | } | |
| 76 | 87 | ||
| 88 | + | log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss") | |
| 77 | 89 | return nil, false | |
| 78 | 90 | } | |
| 79 | 91 | ||
| 80 | 92 | // Set stores favicon bytes for a domain. | |
| 81 | 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 | 95 | if domain == "" || len(data) == 0 { | |
| 84 | 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 | 103 | // Write to memory cache | |
| 88 | 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 | 37 | if domain == "" { | |
| 38 | 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 | 43 | // Internal domain returns the app logo | |
| 42 | 44 | if domain == InternalDomain { | |
| 45 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain") | |
| 43 | 46 | return assets.LogoSVG, nil | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | 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 | 55 | return data, nil | |
| 49 | 56 | } | |
| 50 | 57 | ||
| 51 | 58 | // Fetch from external API | |
| 52 | 59 | data, err := s.fetcher.Fetch(ctx, domain) | |
| 53 | 60 | if err != nil { | |
| 61 | + | log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error") | |
| 54 | 62 | return nil, err | |
| 55 | 63 | } | |
| 56 | 64 | ||
| 57 | 65 | // Store in cache if we got data | |
| 58 | 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 | 74 | return data, nil | |
| 63 | 75 | } | |
| @@ -74,13 +86,19 @@ func GetLogoBytes() []byte { | |||
| 74 | 86 | } | |
| 75 | 87 | ||
| 76 | 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 77 | - | func (s *Service) GetCached(domain string) ([]byte, bool) { | |
| 78 | - | return s.cache.Get(domain) | |
| 89 | + | func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) { | |
| 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 | 99 | // Store saves favicon bytes for a domain to cache. | |
| 82 | - | func (s *Service) Store(domain string, data []byte) error { | |
| 83 | - | s.cache.Set(domain, data) | |
| 100 | + | func (s *Service) Store(ctx context.Context, domain string, data []byte) error { | |
| 101 | + | s.cache.Set(ctx, domain, data) | |
| 84 | 102 | return nil | |
| 85 | 103 | } | |
| 86 | 104 | ||
| @@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||
| 115 | 133 | if domain == "" { | |
| 116 | 134 | return | |
| 117 | 135 | } | |
| 136 | + | log := logging.FromContext(ctx) | |
| 137 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin") | |
| 118 | 138 | ||
| 119 | 139 | // Already on disk? | |
| 120 | 140 | if s.cache.HasOnDisk(domain) { | |
| 141 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk") | |
| 121 | 142 | return | |
| 122 | 143 | } | |
| 123 | 144 | ||
| 124 | - | log := logging.FromContext(ctx) | |
| 125 | - | ||
| 126 | 145 | // Fetch and store | |
| 127 | 146 | data, err := s.fetcher.Fetch(ctx, domain) | |
| 128 | 147 | if err != nil { | |
| @@ -131,8 +150,12 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||
| 131 | 150 | } | |
| 132 | 151 | ||
| 133 | 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 | 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 | 174 | if domain == "" { | |
| 152 | 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 | 183 | // Already exists? | |
| 156 | 184 | if s.HasPNGSizedOnDisk(domain, size) { | |
| 185 | + | log.Debug(). | |
| 186 | + | Str("domain", domain). | |
| 187 | + | Int("size", size). | |
| 188 | + | Msg("favicon: EnsureSizedPNG already exists") | |
| 157 | 189 | return nil | |
| 158 | 190 | } | |
| 159 | 191 | ||
| @@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||
| 168 | 200 | return fmt.Errorf("cannot determine destination path for domain %s", domain) | |
| 169 | 201 | } | |
| 170 | 202 | ||
| 171 | - | log := logging.FromContext(ctx) | |
| 172 | 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 | 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 | 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 | 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 | 51 | Return(nil, nil) // nil filter triggers fallback to download | |
| 52 | 52 | ||
| 53 | 53 | // Fallback: download will be triggered since Load returned nil filter | |
| 54 | + | mockDownloader.EXPECT(). | |
| 55 | + | GetCachedFilterPaths(). | |
| 56 | + | Return(nil) | |
| 57 | + | ||
| 54 | 58 | mockDownloader.EXPECT(). | |
| 55 | 59 | DownloadFilters(mock.Anything, mock.Anything). | |
| 56 | 60 | Return([]string{jsonFile}, nil) | |
| @@ -140,6 +144,10 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||
| 140 | 144 | Return([]string{}, nil) | |
| 141 | 145 | ||
| 142 | 146 | // Download should be triggered | |
| 147 | + | mockDownloader.EXPECT(). | |
| 148 | + | GetCachedFilterPaths(). | |
| 149 | + | Return(nil) | |
| 150 | + | ||
| 143 | 151 | mockDownloader.EXPECT(). | |
| 144 | 152 | DownloadFilters(mock.Anything, mock.Anything). | |
| 145 | 153 | Return([]string{jsonFile}, nil) | |
| @@ -455,6 +463,9 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||
| 455 | 463 | mockStore.EXPECT(). | |
| 456 | 464 | Load(mock.Anything, "ublock-combined-0"). | |
| 457 | 465 | Return(nil, nil) // nil triggers download fallback | |
| 466 | + | mockDownloader.EXPECT(). | |
| 467 | + | GetCachedFilterPaths(). | |
| 468 | + | Return(nil) | |
| 458 | 469 | mockDownloader.EXPECT(). | |
| 459 | 470 | DownloadFilters(mock.Anything, mock.Anything). | |
| 460 | 471 | Return([]string{jsonFile}, nil) | |
| @@ -519,6 +530,9 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) { | |||
| 519 | 530 | mockStore.EXPECT(). | |
| 520 | 531 | FetchIdentifiers(mock.Anything). | |
| 521 | 532 | Return([]string{}, nil) | |
| 533 | + | mockDownloader.EXPECT(). | |
| 534 | + | GetCachedFilterPaths(). | |
| 535 | + | Return(nil) | |
| 522 | 536 | ||
| 523 | 537 | mockDownloader.EXPECT(). | |
| 524 | 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 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| @@ -32,8 +31,8 @@ type autoCopyRequest struct { | |||
| 32 | 31 | ||
| 33 | 32 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |
| 34 | 33 | // It copies the selected text to the clipboard if the feature is enabled. | |
| 35 | - | func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler { | |
| 36 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 34 | + | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |
| 35 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 37 | 36 | log := logging.FromContext(ctx) | |
| 38 | 37 | ||
| 39 | 38 | // Check if feature is enabled | |
| @@ -71,7 +70,7 @@ func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler { | |||
| 71 | 70 | // RegisterClipboardHandlers registers clipboard handlers with the router. | |
| 72 | 71 | func RegisterClipboardHandlers( | |
| 73 | 72 | ctx context.Context, | |
| 74 | - | router *webkit.MessageRouter, | |
| 73 | + | router port.WebUIHandlerRouter, | |
| 75 | 74 | clipboard port.Clipboard, | |
| 76 | 75 | autoCopyConfig port.AutoCopyConfig, | |
| 77 | 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 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 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 | 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 | 28 | if h == nil { | |
| 30 | 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 | 52 | // RegisterConfigHandlers registers configuration handlers with the router. | |
| 54 | 53 | func RegisterConfigHandlers( | |
| 55 | - | ctx context.Context, router *webkit.MessageRouter, | |
| 54 | + | ctx context.Context, router port.WebUIHandlerRouter, | |
| 56 | 55 | saveConfig func(context.Context, port.WebUIConfig) error, | |
| 57 | 56 | ) error { | |
| 58 | 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 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 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 | 22 | // HandleList handles favorite_list messages. | |
| 24 | - | func (h *FavoritesHandlers) HandleList() webkit.MessageHandler { | |
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 23 | + | func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler { | |
| 24 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 26 | 25 | log := logging.FromContext(ctx) | |
| 27 | 26 | ||
| 28 | 27 | requestID := ParseRequestID(payload) | |
| @@ -48,8 +47,8 @@ type setShortcutRequest struct { | |||
| 48 | 47 | } | |
| 49 | 48 | ||
| 50 | 49 | // HandleSetShortcut handles favorite_set_shortcut messages. | |
| 51 | - | func (h *FavoritesHandlers) HandleSetShortcut() webkit.MessageHandler { | |
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 50 | + | func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler { | |
| 51 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 53 | 52 | log := logging.FromContext(ctx) | |
| 54 | 53 | ||
| 55 | 54 | var req setShortcutRequest | |
| @@ -77,8 +76,8 @@ type getByShortcutRequest struct { | |||
| 77 | 76 | } | |
| 78 | 77 | ||
| 79 | 78 | // HandleGetByShortcut handles favorite_get_by_shortcut messages. | |
| 80 | - | func (h *FavoritesHandlers) HandleGetByShortcut() webkit.MessageHandler { | |
| 81 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 79 | + | func (h *FavoritesHandlers) HandleGetByShortcut() port.WebUIMessageHandler { | |
| 80 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 82 | 81 | log := logging.FromContext(ctx) | |
| 83 | 82 | ||
| 84 | 83 | var req getByShortcutRequest | |
| @@ -108,8 +107,8 @@ type setFolderRequest struct { | |||
| 108 | 107 | } | |
| 109 | 108 | ||
| 110 | 109 | // HandleSetFolder handles favorite_set_folder messages. | |
| 111 | - | func (h *FavoritesHandlers) HandleSetFolder() webkit.MessageHandler { | |
| 112 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 110 | + | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |
| 111 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 113 | 112 | log := logging.FromContext(ctx) | |
| 114 | 113 | ||
| 115 | 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 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 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 | 22 | // HandleList handles folder_list messages. | |
| 24 | - | func (h *FolderHandlers) HandleList() webkit.MessageHandler { | |
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 23 | + | func (h *FolderHandlers) HandleList() port.WebUIMessageHandler { | |
| 24 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 26 | 25 | log := logging.FromContext(ctx) | |
| 27 | 26 | ||
| 28 | 27 | requestID := ParseRequestID(payload) | |
| @@ -48,8 +47,8 @@ type createFolderRequest struct { | |||
| 48 | 47 | } | |
| 49 | 48 | ||
| 50 | 49 | // HandleCreate handles folder_create messages. | |
| 51 | - | func (h *FolderHandlers) HandleCreate() webkit.MessageHandler { | |
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 50 | + | func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler { | |
| 51 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 53 | 52 | log := logging.FromContext(ctx) | |
| 54 | 53 | ||
| 55 | 54 | var req createFolderRequest | |
| @@ -86,8 +85,8 @@ type deleteFolderRequest struct { | |||
| 86 | 85 | } | |
| 87 | 86 | ||
| 88 | 87 | // HandleDelete handles folder_delete messages. | |
| 89 | - | func (h *FolderHandlers) HandleDelete() webkit.MessageHandler { | |
| 90 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 88 | + | func (h *FolderHandlers) HandleDelete() port.WebUIMessageHandler { | |
| 89 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 91 | 90 | log := logging.FromContext(ctx) | |
| 92 | 91 | ||
| 93 | 92 | var req deleteFolderRequest | |
| @@ -118,8 +117,8 @@ type updateFolderRequest struct { | |||
| 118 | 117 | ||
| 119 | 118 | // HandleUpdate handles folder_update messages. | |
| 120 | 119 | // It delegates to the port.HomepageFavorites interface's UpdateFolder method. | |
| 121 | - | func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler { | |
| 122 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 120 | + | func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler { | |
| 121 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 123 | 122 | log := logging.FromContext(ctx) | |
| 124 | 123 | ||
| 125 | 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 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| @@ -29,8 +28,8 @@ type timelineRequest struct { | |||
| 29 | 28 | } | |
| 30 | 29 | ||
| 31 | 30 | // HandleTimeline handles history_timeline messages. | |
| 32 | - | func (h *HistoryHandlers) HandleTimeline() webkit.MessageHandler { | |
| 33 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 31 | + | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |
| 32 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 34 | 33 | log := logging.FromContext(ctx) | |
| 35 | 34 | ||
| 36 | 35 | var req timelineRequest | |
| @@ -61,8 +60,8 @@ type searchRequest struct { | |||
| 61 | 60 | } | |
| 62 | 61 | ||
| 63 | 62 | // HandleSearchFTS handles history_search_fts messages. | |
| 64 | - | func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler { | |
| 65 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 63 | + | func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler { | |
| 64 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 66 | 65 | log := logging.FromContext(ctx) | |
| 67 | 66 | ||
| 68 | 67 | var req searchRequest | |
| @@ -100,8 +99,8 @@ type deleteEntryRequest struct { | |||
| 100 | 99 | } | |
| 101 | 100 | ||
| 102 | 101 | // HandleDeleteEntry handles history_delete_entry messages. | |
| 103 | - | func (h *HistoryHandlers) HandleDeleteEntry() webkit.MessageHandler { | |
| 104 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 102 | + | func (h *HistoryHandlers) HandleDeleteEntry() port.WebUIMessageHandler { | |
| 103 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 105 | 104 | log := logging.FromContext(ctx) | |
| 106 | 105 | ||
| 107 | 106 | var req deleteEntryRequest | |
| @@ -129,8 +128,8 @@ type deleteRangeRequest struct { | |||
| 129 | 128 | } | |
| 130 | 129 | ||
| 131 | 130 | // HandleDeleteRange handles history_delete_range messages. | |
| 132 | - | func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler { | |
| 133 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 131 | + | func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler { | |
| 132 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 134 | 133 | log := logging.FromContext(ctx) | |
| 135 | 134 | ||
| 136 | 135 | var req deleteRangeRequest | |
| @@ -160,8 +159,8 @@ func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler { | |||
| 160 | 159 | } | |
| 161 | 160 | ||
| 162 | 161 | // HandleClearAll handles history_clear_all messages. | |
| 163 | - | func (h *HistoryHandlers) HandleClearAll() webkit.MessageHandler { | |
| 164 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 162 | + | func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | |
| 163 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 165 | 164 | log := logging.FromContext(ctx) | |
| 166 | 165 | ||
| 167 | 166 | requestID := ParseRequestID(payload) | |
| @@ -199,8 +198,8 @@ func rangeToTime(r string) time.Time { | |||
| 199 | 198 | } | |
| 200 | 199 | ||
| 201 | 200 | // HandleAnalytics handles history_analytics messages. | |
| 202 | - | func (h *HistoryHandlers) HandleAnalytics() webkit.MessageHandler { | |
| 203 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 201 | + | func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler { | |
| 202 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 204 | 203 | log := logging.FromContext(ctx) | |
| 205 | 204 | ||
| 206 | 205 | requestID := ParseRequestID(payload) | |
| @@ -225,8 +224,8 @@ type domainStatsRequest struct { | |||
| 225 | 224 | } | |
| 226 | 225 | ||
| 227 | 226 | // HandleDomainStats handles history_domain_stats messages. | |
| 228 | - | func (h *HistoryHandlers) HandleDomainStats() webkit.MessageHandler { | |
| 229 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 227 | + | func (h *HistoryHandlers) HandleDomainStats() port.WebUIMessageHandler { | |
| 228 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 230 | 229 | log := logging.FromContext(ctx) | |
| 231 | 230 | ||
| 232 | 231 | var req domainStatsRequest | |
| @@ -255,8 +254,8 @@ type deleteDomainRequest struct { | |||
| 255 | 254 | } | |
| 256 | 255 | ||
| 257 | 256 | // HandleDeleteDomain handles history_delete_domain messages. | |
| 258 | - | func (h *HistoryHandlers) HandleDeleteDomain() webkit.MessageHandler { | |
| 259 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 257 | + | func (h *HistoryHandlers) HandleDeleteDomain() port.WebUIMessageHandler { | |
| 258 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 260 | 259 | log := logging.FromContext(ctx) | |
| 261 | 260 | ||
| 262 | 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 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| @@ -16,7 +15,7 @@ type Config struct { | |||
| 16 | 15 | } | |
| 17 | 16 | ||
| 18 | 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 | 19 | log := logging.FromContext(ctx) | |
| 21 | 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 | 24 | const errorCallback = "__dumber_error" | |
| 26 | 25 | const worldName = "" // main world | |
| 27 | 26 | ||
| 28 | - | handlers := make(map[string]webkit.MessageHandler) | |
| 27 | + | handlers := make(map[string]port.WebUIMessageHandler) | |
| 29 | 28 | ||
| 30 | 29 | // History handlers | |
| 31 | 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 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 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 | 22 | // HandleList handles tag_list messages. | |
| 24 | - | func (h *TagHandlers) HandleList() webkit.MessageHandler { | |
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 23 | + | func (h *TagHandlers) HandleList() port.WebUIMessageHandler { | |
| 24 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 26 | 25 | log := logging.FromContext(ctx) | |
| 27 | 26 | ||
| 28 | 27 | requestID := ParseRequestID(payload) | |
| @@ -48,8 +47,8 @@ type createTagRequest struct { | |||
| 48 | 47 | } | |
| 49 | 48 | ||
| 50 | 49 | // HandleCreate handles tag_create messages. | |
| 51 | - | func (h *TagHandlers) HandleCreate() webkit.MessageHandler { | |
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 50 | + | func (h *TagHandlers) HandleCreate() port.WebUIMessageHandler { | |
| 51 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 53 | 52 | log := logging.FromContext(ctx) | |
| 54 | 53 | ||
| 55 | 54 | var req createTagRequest | |
| @@ -83,8 +82,8 @@ type deleteTagRequest struct { | |||
| 83 | 82 | } | |
| 84 | 83 | ||
| 85 | 84 | // HandleDelete handles tag_delete messages. | |
| 86 | - | func (h *TagHandlers) HandleDelete() webkit.MessageHandler { | |
| 87 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 85 | + | func (h *TagHandlers) HandleDelete() port.WebUIMessageHandler { | |
| 86 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 88 | 87 | log := logging.FromContext(ctx) | |
| 89 | 88 | ||
| 90 | 89 | var req deleteTagRequest | |
| @@ -115,8 +114,8 @@ type updateTagRequest struct { | |||
| 115 | 114 | ||
| 116 | 115 | // HandleUpdate handles tag_update messages. | |
| 117 | 116 | // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase. | |
| 118 | - | func (h *TagHandlers) HandleUpdate() webkit.MessageHandler { | |
| 119 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 117 | + | func (h *TagHandlers) HandleUpdate() port.WebUIMessageHandler { | |
| 118 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 120 | 119 | log := logging.FromContext(ctx) | |
| 121 | 120 | ||
| 122 | 121 | var req updateTagRequest | |
| @@ -154,17 +153,17 @@ type tagAssignRequest struct { | |||
| 154 | 153 | } | |
| 155 | 154 | ||
| 156 | 155 | // HandleAssign handles tag_assign messages. | |
| 157 | - | func (h *TagHandlers) HandleAssign() webkit.MessageHandler { | |
| 156 | + | func (h *TagHandlers) HandleAssign() port.WebUIMessageHandler { | |
| 158 | 157 | return handleTagAssign("tag_assign", h.favoritesUC.TagFavorite) | |
| 159 | 158 | } | |
| 160 | 159 | ||
| 161 | 160 | // HandleRemove handles tag_remove messages. | |
| 162 | - | func (h *TagHandlers) HandleRemove() webkit.MessageHandler { | |
| 161 | + | func (h *TagHandlers) HandleRemove() port.WebUIMessageHandler { | |
| 163 | 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 { | |
| 167 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 165 | + | func handleTagAssign(action string, op func(context.Context, entity.FavoriteID, entity.TagID) error) port.WebUIMessageHandler { | |
| 166 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 168 | 167 | log := logging.FromContext(ctx) | |
| 169 | 168 | ||
| 170 | 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 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| @@ -34,12 +33,12 @@ func NewKeybindingsHandler( | |||
| 34 | 33 | } | |
| 35 | 34 | ||
| 36 | 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 | 37 | return h.getUC.Execute(ctx) | |
| 39 | 38 | } | |
| 40 | 39 | ||
| 41 | 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 | 42 | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |
| 44 | 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 | 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 | 64 | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |
| 66 | 65 | ||
| 67 | 66 | var req port.ResetKeybindingRequest | |
| @@ -79,7 +78,7 @@ func (h *KeybindingsHandler) HandleResetKeybinding(ctx context.Context, _ webkit | |||
| 79 | 78 | } | |
| 80 | 79 | ||
| 81 | 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 | 82 | log := logging.FromContext(ctx).With().Str("handler", "keybindings").Logger() | |
| 84 | 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 | 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 | 94 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | |
| 96 | 95 | ||
| 97 | 96 | // Get all keybindings | |
| @@ -100,7 +99,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout | |||
| 100 | 99 | "__dumber_keybindings_loaded", | |
| 101 | 100 | "__dumber_keybindings_error", | |
| 102 | 101 | "", | |
| 103 | - | webkit.MessageHandlerFunc(handler.HandleGetKeybindings), | |
| 102 | + | port.WebUIMessageHandlerFunc(handler.HandleGetKeybindings), | |
| 104 | 103 | ); err != nil { | |
| 105 | 104 | return err | |
| 106 | 105 | } | |
| @@ -111,7 +110,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout | |||
| 111 | 110 | "__dumber_keybinding_set", | |
| 112 | 111 | "__dumber_keybinding_set_error", | |
| 113 | 112 | "", | |
| 114 | - | webkit.MessageHandlerFunc(handler.HandleSetKeybinding), | |
| 113 | + | port.WebUIMessageHandlerFunc(handler.HandleSetKeybinding), | |
| 115 | 114 | ); err != nil { | |
| 116 | 115 | return err | |
| 117 | 116 | } | |
| @@ -122,7 +121,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout | |||
| 122 | 121 | "__dumber_keybinding_reset", | |
| 123 | 122 | "__dumber_keybinding_reset_error", | |
| 124 | 123 | "", | |
| 125 | - | webkit.MessageHandlerFunc(handler.HandleResetKeybinding), | |
| 124 | + | port.WebUIMessageHandlerFunc(handler.HandleResetKeybinding), | |
| 126 | 125 | ); err != nil { | |
| 127 | 126 | return err | |
| 128 | 127 | } | |
| @@ -133,7 +132,7 @@ func RegisterKeybindingsHandlers(ctx context.Context, router *webkit.MessageRout | |||
| 133 | 132 | "__dumber_keybindings_reset_all", | |
| 134 | 133 | "__dumber_keybindings_reset_all_error", | |
| 135 | 134 | "", | |
| 136 | - | webkit.MessageHandlerFunc(handler.HandleResetAllKeybindings), | |
| 135 | + | port.WebUIMessageHandlerFunc(handler.HandleResetAllKeybindings), | |
| 137 | 136 | ); err != nil { | |
| 138 | 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 | 7 | "unicode/utf8" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/handlers/homepage" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // AccentKeyHandler is implemented by the InsertAccentUseCase to receive | |
| @@ -17,49 +17,43 @@ import ( | |||
| 17 | 17 | // Deprecated: use port.AccentKeyHandler instead. This alias is kept for compatibility. | |
| 18 | 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 | 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 | 23 | // Homepage handlers (history, favorites, folders, tags) | |
| 36 | - | if cfg.HistoryUC != nil && cfg.FavoritesUC != nil { | |
| 24 | + | if deps.HistoryUC != nil && deps.FavoritesUC != nil { | |
| 37 | 25 | if err := homepage.RegisterHandlers(ctx, router, homepage.Config{ | |
| 38 | - | HistoryUC: cfg.HistoryUC, | |
| 39 | - | FavoritesUC: cfg.FavoritesUC, | |
| 26 | + | HistoryUC: deps.HistoryUC, | |
| 27 | + | FavoritesUC: deps.FavoritesUC, | |
| 40 | 28 | }); err != nil { | |
| 41 | 29 | return err | |
| 42 | 30 | } | |
| 43 | 31 | } | |
| 44 | 32 | ||
| 45 | 33 | // Configuration handlers | |
| 46 | - | if cfg.SaveConfig != nil { | |
| 47 | - | if err := RegisterConfigHandlers(ctx, router, cfg.SaveConfig); err != nil { | |
| 34 | + | if deps.SaveConfig != nil { | |
| 35 | + | if err := RegisterConfigHandlers(ctx, router, deps.SaveConfig); err != nil { | |
| 48 | 36 | return err | |
| 49 | 37 | } | |
| 50 | 38 | } | |
| 51 | 39 | ||
| 52 | 40 | // Keybindings handlers (always available) | |
| 53 | - | if cfg.KeybindingsHandler == nil { | |
| 54 | - | return fmt.Errorf("KeybindingsHandler is required") | |
| 41 | + | if deps.KeybindingsGetter == nil { | |
| 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 | 51 | return err | |
| 58 | 52 | } | |
| 59 | 53 | ||
| 60 | 54 | // Clipboard handlers (for auto-copy on selection feature) | |
| 61 | - | if cfg.Clipboard != nil && cfg.AutoCopyConfig != nil { | |
| 62 | - | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.AutoCopyConfig, cfg.OnClipboardCopied); err != nil { | |
| 55 | + | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | |
| 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | |
| 63 | 57 | return err | |
| 64 | 58 | } | |
| 65 | 59 | } | |
| @@ -69,7 +63,7 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 69 | 63 | ||
| 70 | 64 | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |
| 71 | 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 | 67 | if router == nil { | |
| 74 | 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 | 71 | return fmt.Errorf("RegisterAccentHandlers: handler must not be nil") | |
| 78 | 72 | } | |
| 79 | 73 | ||
| 80 | - | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |
| 81 | - | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 74 | + | if err := router.RegisterHandler("accent_key_press", port.WebUIMessageHandlerFunc( | |
| 75 | + | func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 82 | 76 | var p struct { | |
| 83 | 77 | Char string `json:"char"` | |
| 84 | 78 | Shift bool `json:"shift"` | |
| @@ -88,6 +82,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han | |||
| 88 | 82 | } | |
| 89 | 83 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 90 | 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 | 88 | return nil, nil | |
| 93 | 89 | }, | |
| @@ -95,8 +91,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han | |||
| 95 | 91 | return err | |
| 96 | 92 | } | |
| 97 | 93 | ||
| 98 | - | if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc( | |
| 99 | - | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 94 | + | if err := router.RegisterHandler("accent_key_release", port.WebUIMessageHandlerFunc( | |
| 95 | + | func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 100 | 96 | var p struct { | |
| 101 | 97 | Char string `json:"char"` | |
| 102 | 98 | } | |
| @@ -105,6 +101,8 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han | |||
| 105 | 101 | } | |
| 106 | 102 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 107 | 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 | 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 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 10 | 11 | "github.com/rs/zerolog" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -23,10 +24,6 @@ type Engine struct { | |||
| 23 | 24 | schemeHandler *DumbSchemeHandler | |
| 24 | 25 | schemePath string | |
| 25 | 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 | 29 | // Compile-time check that Engine implements port.Engine. | |
| @@ -95,10 +92,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||
| 95 | 92 | if e.messageRouter == nil { | |
| 96 | 93 | return fmt.Errorf("message router not initialized") | |
| 97 | 94 | } | |
| 98 | - | if e.handlerRegistrar == nil { | |
| 99 | - | return fmt.Errorf("handler registrar not configured") | |
| 100 | - | } | |
| 101 | - | return e.handlerRegistrar(ctx, e.messageRouter, deps) | |
| 95 | + | return handlers.RegisterAll(ctx, e.messageRouter, deps) | |
| 102 | 96 | } | |
| 103 | 97 | ||
| 104 | 98 | // RegisterAccentHandlers registers accent/dead-key input handlers. | |
| @@ -106,10 +100,7 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 106 | 100 | if e.messageRouter == nil { | |
| 107 | 101 | return fmt.Errorf("message router not initialized") | |
| 108 | 102 | } | |
| 109 | - | if e.accentHandlerRegistrar == nil { | |
| 110 | - | return fmt.Errorf("accent handler registrar not configured") | |
| 111 | - | } | |
| 112 | - | return e.accentHandlerRegistrar(ctx, e.messageRouter, handler) | |
| 103 | + | return handlers.RegisterAccentHandlers(ctx, e.messageRouter, handler) | |
| 113 | 104 | } | |
| 114 | 105 | ||
| 115 | 106 | // ConfigureDownloads sets up download handling. | |
M internal/infrastructure/webkit/engine_init.go +11 -15
| @@ -26,8 +26,6 @@ func NewEngine( | |||
| 26 | 26 | themeManager *theme.Manager, | |
| 27 | 27 | colorResolver port.ColorSchemeResolver, | |
| 28 | 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 | 29 | ) (*Engine, error) { | |
| 32 | 30 | // --- Hardware survey and performance profile resolution --- | |
| 33 | 31 | perfSettings := engineSurveyHardwareAndResolveProfile(ctx, cfg, logger) | |
| @@ -106,19 +104,17 @@ func NewEngine( | |||
| 106 | 104 | ||
| 107 | 105 | // --- Assemble Engine --- | |
| 108 | 106 | engine := &Engine{ | |
| 109 | - | ctx: ctx, | |
| 110 | - | wkCtx: wkCtx, | |
| 111 | - | settings: settings, | |
| 112 | - | injector: injector, | |
| 113 | - | messageRouter: messageRouter, | |
| 114 | - | pool: pool, | |
| 115 | - | factory: factory, | |
| 116 | - | filterManager: filterManager, | |
| 117 | - | schemeHandler: schemeHandler, | |
| 118 | - | schemePath: "dumb://", | |
| 119 | - | logger: logger, | |
| 120 | - | handlerRegistrar: handlerRegistrar, | |
| 121 | - | accentHandlerRegistrar: accentHandlerRegistrar, | |
| 107 | + | ctx: ctx, | |
| 108 | + | wkCtx: wkCtx, | |
| 109 | + | settings: settings, | |
| 110 | + | injector: injector, | |
| 111 | + | messageRouter: messageRouter, | |
| 112 | + | pool: pool, | |
| 113 | + | factory: factory, | |
| 114 | + | filterManager: filterManager, | |
| 115 | + | schemeHandler: schemeHandler, | |
| 116 | + | schemePath: "dumb://", | |
| 117 | + | logger: logger, | |
| 122 | 118 | } | |
| 123 | 119 | ||
| 124 | 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 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 14 | ) | |
| @@ -28,91 +29,6 @@ const themeCSSScript = `(function() { | |||
| 28 | 29 | (document.head || document.documentElement).appendChild(style); | |
| 29 | 30 | })();` | |
| 30 | 31 | ||
| 31 | - | // internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*). | |
| 32 | - | // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia | |
| 33 | - | // for JS-based dark mode detection in WebUI components. | |
| 34 | - | // | |
| 35 | - | // NOTE: This script is NOT injected on external pages. External pages receive | |
| 36 | - | // dark mode preference via libadwaita's StyleManager, which WebKit respects | |
| 37 | - | // for the native prefers-color-scheme media query. | |
| 38 | - | // | |
| 39 | - | // The matchMedia patch handles various query formats: | |
| 40 | - | // - (prefers-color-scheme: dark) | |
| 41 | - | // - (prefers-color-scheme:dark) -- no space | |
| 42 | - | // - screen and (prefers-color-scheme: dark) | |
| 43 | - | const internalDarkModeScriptTemplate = `(function() { | |
| 44 | - | var prefersDark = %t; | |
| 45 | - | window.__dumber_gtk_prefers_dark = prefersDark; | |
| 46 | - | var originalMatchMedia = window.matchMedia.bind(window); | |
| 47 | - | ||
| 48 | - | // Apply dark/light class to document element for Tailwind CSS theming | |
| 49 | - | if (prefersDark) { | |
| 50 | - | document.documentElement.classList.add('dark'); | |
| 51 | - | document.documentElement.classList.remove('light'); | |
| 52 | - | } else { | |
| 53 | - | document.documentElement.classList.add('light'); | |
| 54 | - | document.documentElement.classList.remove('dark'); | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // Helper: Check if query is a prefers-color-scheme query | |
| 58 | - | function isColorSchemeQuery(query, scheme) { | |
| 59 | - | if (typeof query !== 'string') return false; | |
| 60 | - | var normalized = query.replace(/\s+/g, '').toLowerCase(); | |
| 61 | - | return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Create a fake MediaQueryList that implements the full interface | |
| 65 | - | function createFakeMediaQueryList(query, matches) { | |
| 66 | - | var listeners = []; | |
| 67 | - | var onchangeHandler = null; | |
| 68 | - | ||
| 69 | - | return { | |
| 70 | - | matches: matches, | |
| 71 | - | media: query, | |
| 72 | - | get onchange() { return onchangeHandler; }, | |
| 73 | - | set onchange(fn) { onchangeHandler = fn; }, | |
| 74 | - | addListener: function(cb) { | |
| 75 | - | if (typeof cb === 'function') listeners.push(cb); | |
| 76 | - | }, | |
| 77 | - | removeListener: function(cb) { | |
| 78 | - | var idx = listeners.indexOf(cb); | |
| 79 | - | if (idx !== -1) listeners.splice(idx, 1); | |
| 80 | - | }, | |
| 81 | - | addEventListener: function(type, cb) { | |
| 82 | - | if (type === 'change' && typeof cb === 'function') { | |
| 83 | - | listeners.push(cb); | |
| 84 | - | } | |
| 85 | - | }, | |
| 86 | - | removeEventListener: function(type, cb) { | |
| 87 | - | if (type === 'change') { | |
| 88 | - | var idx = listeners.indexOf(cb); | |
| 89 | - | if (idx !== -1) listeners.splice(idx, 1); | |
| 90 | - | } | |
| 91 | - | }, | |
| 92 | - | dispatchEvent: function(event) { | |
| 93 | - | for (var i = 0; i < listeners.length; i++) { | |
| 94 | - | try { listeners[i](event); } catch (e) {} | |
| 95 | - | } | |
| 96 | - | if (onchangeHandler) { | |
| 97 | - | try { onchangeHandler(event); } catch (e) {} | |
| 98 | - | } | |
| 99 | - | return true; | |
| 100 | - | } | |
| 101 | - | }; | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // Patch matchMedia for prefers-color-scheme queries | |
| 105 | - | window.matchMedia = function(query) { | |
| 106 | - | if (isColorSchemeQuery(query, 'dark')) { | |
| 107 | - | return createFakeMediaQueryList(query, prefersDark); | |
| 108 | - | } | |
| 109 | - | if (isColorSchemeQuery(query, 'light')) { | |
| 110 | - | return createFakeMediaQueryList(query, !prefersDark); | |
| 111 | - | } | |
| 112 | - | return originalMatchMedia(query); | |
| 113 | - | }; | |
| 114 | - | })();` | |
| 115 | - | ||
| 116 | 32 | // internalPageAllowList restricts script injection to internal dumb:// pages only. | |
| 117 | 33 | var internalPageAllowList = []string{"dumb://*"} | |
| 118 | 34 | ||
| @@ -333,7 +249,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 333 | 249 | ||
| 334 | 250 | // 3. Inject dark mode handler for internal pages only | |
| 335 | 251 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 336 | - | internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark) | |
| 252 | + | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | |
| 337 | 253 | addScript( | |
| 338 | 254 | webkit.NewUserScript( | |
| 339 | 255 | internalDarkModeScript, | |
| @@ -347,10 +263,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 347 | 263 | ||
| 348 | 264 | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 349 | 265 | if ci.themeCSSVars != "" { | |
| 350 | - | // Escape for JS string literal | |
| 351 | - | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |
| 352 | - | escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'") | |
| 353 | - | escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n") | |
| 266 | + | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | |
| 354 | 267 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | |
| 355 | 268 | addScript( | |
| 356 | 269 | webkit.NewUserScript( | |
M internal/infrastructure/webkit/messaging.go +12 -18
| @@ -7,31 +7,22 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 12 | "github.com/bnema/puregotk-webkit/javascriptcore" | |
| 12 | 13 | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | - | // Message represents a JS -> Go message envelope sent via postMessage. | |
| 16 | - | type Message struct { | |
| 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 | - | } | |
| 16 | + | // Message is the JS -> Go message envelope sent via postMessage. | |
| 17 | + | type Message = port.WebUIMessage | |
| 22 | 18 | ||
| 23 | - | // MessageHandler handles a decoded message payload. | |
| 24 | - | type MessageHandler interface { | |
| 25 | - | Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) | |
| 26 | - | } | |
| 19 | + | // MessageHandler is an alias for port.WebUIMessageHandler. | |
| 20 | + | // Kept for backward compatibility within the webkit package. | |
| 21 | + | type MessageHandler = port.WebUIMessageHandler | |
| 27 | 22 | ||
| 28 | - | // MessageHandlerFunc adapts a function to the MessageHandler interface. | |
| 29 | - | type MessageHandlerFunc func(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) | |
| 30 | - | ||
| 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 | - | } | |
| 23 | + | // MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc. | |
| 24 | + | // Kept for backward compatibility within the webkit package. | |
| 25 | + | type MessageHandlerFunc = port.WebUIMessageHandlerFunc | |
| 35 | 26 | ||
| 36 | 27 | type handlerEntry struct { | |
| 37 | 28 | handler MessageHandler | |
| @@ -40,6 +31,9 @@ type handlerEntry struct { | |||
| 40 | 31 | world string | |
| 41 | 32 | } | |
| 42 | 33 | ||
| 34 | + | // Compile-time check that MessageRouter implements port.WebUIHandlerRouter. | |
| 35 | + | var _ port.WebUIHandlerRouter = (*MessageRouter)(nil) | |
| 36 | + | ||
| 43 | 37 | // MessageRouter dispatches script-message events to registered handlers. | |
| 44 | 38 | type MessageRouter struct { | |
| 45 | 39 | handlers map[string]handlerEntry | |
M internal/infrastructure/webkit/scheme_handler.go +5 -282
| @@ -5,9 +5,7 @@ import ( | |||
| 5 | 5 | "embed" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | - | "html" | |
| 9 | 8 | "io/fs" | |
| 10 | - | "mime" | |
| 11 | 9 | "net/http" | |
| 12 | 10 | "net/url" | |
| 13 | 11 | "path/filepath" | |
| @@ -17,6 +15,7 @@ import ( | |||
| 17 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 20 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 21 | 20 | "github.com/bnema/puregotk-webkit/soup" | |
| 22 | 21 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -74,60 +73,6 @@ type DumbSchemeHandler struct { | |||
| 74 | 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 | 76 | // NewDumbSchemeHandler creates a new handler for the dumb:// scheme. | |
| 132 | 77 | func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |
| 133 | 78 | log := logging.FromContext(ctx) | |
| @@ -209,131 +154,11 @@ func crashOriginalURI(requestURI string) string { | |||
| 209 | 154 | } | |
| 210 | 155 | ||
| 211 | 156 | func sanitizeCrashPageOriginalURI(originalURI string) string { | |
| 212 | - | originalURI = strings.TrimSpace(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 | - | } | |
| 157 | + | return webutil.SanitizeCrashPageOriginalURI(originalURI) | |
| 234 | 158 | } | |
| 235 | 159 | ||
| 236 | 160 | func buildCrashPageHTML(originalURI string) string { | |
| 237 | - | escapedURI := html.EscapeString(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) | |
| 161 | + | return webutil.BuildCrashPageHTML(originalURI) | |
| 337 | 162 | } | |
| 338 | 163 | ||
| 339 | 164 | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| @@ -346,44 +171,7 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo | |||
| 346 | 171 | hw = &hwInfo | |
| 347 | 172 | } | |
| 348 | 173 | ||
| 349 | - | // Resolve performance profile with hardware info | |
| 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 | - | } | |
| 174 | + | resp := config.BuildWebUIConfigPayload(cfg, hw) | |
| 387 | 175 | ||
| 388 | 176 | data, err := json.Marshal(resp) | |
| 389 | 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 | 192 | // RegisterPage registers a handler for a specific path. | |
| 420 | 193 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | |
| 421 | 194 | h.mu.Lock() | |
| @@ -526,7 +299,7 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 526 | 299 | return nil | |
| 527 | 300 | } | |
| 528 | 301 | ||
| 529 | - | contentType := h.getMimeType(relPath) | |
| 302 | + | contentType := webutil.GetMimeType(relPath) | |
| 530 | 303 | h.logger.Debug(). | |
| 531 | 304 | Str("path", fullPath). | |
| 532 | 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 | 350 | // sendResponse sends the response back to WebKit. | |
| 628 | 351 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | |
| 629 | 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 | 1 | package logging | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "io" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "sync" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/rs/zerolog" | |
| @@ -105,8 +107,9 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||
| 105 | 107 | return zerolog.Logger{}, nil, err | |
| 106 | 108 | } | |
| 107 | 109 | ||
| 108 | - | writers = append(writers, file) | |
| 109 | - | cleanup = func() { _ = file.Close() } | |
| 110 | + | sessionWriter := newSessionFileWriter(file) | |
| 111 | + | writers = append(writers, sessionWriter) | |
| 112 | + | cleanup = func() { _ = sessionWriter.Close() } | |
| 110 | 113 | } | |
| 111 | 114 | ||
| 112 | 115 | // Fallback to discard if no writers configured | |
| @@ -123,6 +126,51 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||
| 123 | 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 | 174 | // NewFromEnv creates a logger based on environment variables | |
| 127 | 175 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | |
| 128 | 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 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "os" | |
| 6 | 7 | "sync" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 11 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -28,10 +30,17 @@ type FaviconAdapter struct { | |||
| 28 | 30 | internalDomain string | |
| 29 | 31 | normalizedIconSize int | |
| 30 | 32 | getLogoBytes func() []byte | |
| 33 | + | disabled bool | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 36 | + | const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS" | |
| 37 | + | ||
| 33 | 38 | type warningLogFunc func(log *zerolog.Logger, err error) | |
| 34 | 39 | ||
| 40 | + | type textureResult struct { | |
| 41 | + | texture *gdk.Texture | |
| 42 | + | } | |
| 43 | + | ||
| 35 | 44 | // FaviconAdapterConfig holds optional configuration for FaviconAdapter. | |
| 36 | 45 | // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation). | |
| 37 | 46 | type FaviconAdapterConfig struct { | |
| @@ -48,6 +57,7 @@ type FaviconAdapterConfig struct { | |||
| 48 | 57 | // NewFaviconAdapter creates a new FaviconAdapter. | |
| 49 | 58 | // The faviconDB can be nil if a favicon database is not available. | |
| 50 | 59 | func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { | |
| 60 | + | disabled := os.Getenv(disableFaviconsEnv) == "1" | |
| 51 | 61 | return &FaviconAdapter{ | |
| 52 | 62 | service: service, | |
| 53 | 63 | faviconDB: faviconDB, | |
| @@ -57,6 +67,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba | |||
| 57 | 67 | internalDomain: cfg.InternalDomain, | |
| 58 | 68 | normalizedIconSize: cfg.NormalizedIconSize, | |
| 59 | 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 | 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 | 114 | // Handle internal dumb:// scheme URLs - use app logo | |
| 97 | 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 98 | - | texture := a.getOrCreateLogoTexture() | |
| 99 | - | callback(texture) | |
| 116 | + | callback(a.getOrCreateLogoTexture(ctx)) | |
| 100 | 117 | return | |
| 101 | 118 | } | |
| 102 | 119 | ||
| 103 | 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 | 126 | // Check texture cache first | |
| 106 | 127 | if texture := a.GetTexture(domain); texture != nil { | |
| 128 | + | log.Debug().Str("domain", domain).Msg("favicon: texture cache hit") | |
| 107 | 129 | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| 108 | 130 | go a.ensureSizedPNG(ctx, domain) | |
| 109 | 131 | callback(texture) | |
| @@ -111,8 +133,12 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 111 | 133 | } | |
| 112 | 134 | ||
| 113 | 135 | // Check service cache (memory + disk) and convert to texture | |
| 114 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 115 | - | texture := bytesToTexture(data) | |
| 136 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 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 | 142 | if texture != nil { | |
| 117 | 143 | a.setTexture(domain, texture) | |
| 118 | 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 | 151 | // Try engine FaviconDatabase if available | |
| 152 | 152 | if a.faviconDB != nil { | |
| 153 | 153 | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | |
| 154 | - | if tex == nil { | |
| 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) | |
| 154 | + | a.handleEngineFaviconDBTexture(ctx, domain, callback, tex) | |
| 168 | 155 | }) | |
| 169 | 156 | return | |
| 170 | 157 | } | |
| 171 | 158 | ||
| 172 | 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 | 234 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | |
| 177 | 235 | // Also ensures the favicon is persisted to disk cache via the service. | |
| 178 | 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 | 243 | if texture == nil { | |
| 180 | 244 | return | |
| 181 | 245 | } | |
| @@ -271,26 +335,44 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 271 | 335 | ||
| 272 | 336 | // PreloadFromCache attempts to load a favicon from cache without fetching. | |
| 273 | 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 | 346 | domain := domainurl.ExtractDomain(pageURL) | |
| 276 | 347 | if domain == "" { | |
| 277 | 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 | 356 | // Check texture cache | |
| 281 | 357 | if texture := a.GetTexture(domain); texture != nil { | |
| 358 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit") | |
| 282 | 359 | return texture | |
| 283 | 360 | } | |
| 284 | 361 | ||
| 285 | 362 | // Check service cache (memory + disk) | |
| 286 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 287 | - | texture := bytesToTexture(data) | |
| 363 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 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 | 369 | if texture != nil { | |
| 289 | 370 | a.setTexture(domain, texture) | |
| 290 | 371 | return texture | |
| 291 | 372 | } | |
| 292 | 373 | } | |
| 293 | 374 | ||
| 375 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss") | |
| 294 | 376 | return nil | |
| 295 | 377 | } | |
| 296 | 378 | ||
| @@ -402,7 +484,7 @@ func (a *FaviconAdapter) logWarningDedup( | |||
| 402 | 484 | ||
| 403 | 485 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | |
| 404 | 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 | 488 | if a.internalDomain == "" || a.getLogoBytes == nil { | |
| 407 | 489 | return nil | |
| 408 | 490 | } | |
| @@ -414,13 +496,45 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||
| 414 | 496 | ||
| 415 | 497 | // Create texture from logo bytes | |
| 416 | 498 | logoBytes := a.getLogoBytes() | |
| 417 | - | texture := bytesToTexture(logoBytes) | |
| 499 | + | texture := a.textureFromBytesOnGTK(ctx, logoBytes) | |
| 418 | 500 | if texture != nil { | |
| 419 | 501 | a.setTexture(a.internalDomain, texture) | |
| 420 | 502 | } | |
| 421 | 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 | 538 | // bytesToTexture converts raw favicon bytes to a GDK texture. | |
| 425 | 539 | func bytesToTexture(data []byte) *gdk.Texture { | |
| 426 | 540 | if len(data) == 0 { | |
M internal/ui/app.go +19 -4
| @@ -50,6 +50,15 @@ func gtkApplicationFlags() gio.ApplicationFlags { | |||
| 50 | 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 | 62 | // App wraps the GTK Application and manages the browser lifecycle. | |
| 54 | 63 | type App struct { | |
| 55 | 64 | deps *Dependencies | |
| @@ -195,6 +204,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 195 | 204 | }) | |
| 196 | 205 | glib.IdleAdd(&cb, 0) | |
| 197 | 206 | }, | |
| 207 | + | HandlerDeps: deps.HandlerDeps, | |
| 198 | 208 | }); err != nil { | |
| 199 | 209 | return nil, err | |
| 200 | 210 | } | |
| @@ -210,7 +220,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 210 | 220 | ||
| 211 | 221 | // Initialize libadwaita once (required before using StyleManager). | |
| 212 | 222 | // This also initializes GTK implicitly. | |
| 213 | - | adw.Init() | |
| 223 | + | EnsureAdwaitaInitialized() | |
| 214 | 224 | logging.Trace().Mark("gtk_init") | |
| 215 | 225 | ||
| 216 | 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 | 383 | func (a *App) createMainWindow(ctx context.Context) error { | |
| 384 | + | log := logging.FromContext(ctx) | |
| 374 | 385 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | |
| 375 | 386 | if err != nil { | |
| 376 | 387 | return err | |
| 377 | 388 | } | |
| 378 | 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 | 397 | // Create permission popup and dialog presenter | |
| 381 | 398 | if a.deps != nil && a.deps.PermissionUC != nil { | |
| 382 | 399 | uiScale := 1.0 | |
| @@ -927,12 +944,10 @@ func (a *App) initSessionManager(ctx context.Context) { | |||
| 927 | 944 | listSessionsUC = usecase.NewListSessionsUseCase( | |
| 928 | 945 | a.deps.SessionRepo, | |
| 929 | 946 | a.deps.SessionStateRepo, | |
| 930 | - | a.deps.Config.Logging.LogDir, | |
| 931 | 947 | ) | |
| 932 | 948 | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |
| 933 | 949 | a.deps.SessionStateRepo, | |
| 934 | 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 | 2579 | // regular workspace panes. This allows the content coordinator's | |
| 2565 | 2580 | // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find | |
| 2566 | 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 | 2583 | pv.SetActive(true) | |
| 2569 | 2584 | // Hide loading skeleton — floating panes have their own themed container. | |
| 2570 | 2585 | pv.HideLoadingSkeleton() | |
M internal/ui/component/pane_view.go +34 -2
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | "github.com/bnema/dumber/internal/ui/input" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/layout" | |
| 11 | 12 | ) | |
| @@ -18,6 +19,7 @@ const ( | |||
| 18 | 19 | // PaneView is a container for a single WebView with active state indication. | |
| 19 | 20 | // It uses an overlay to display a border around the active pane. | |
| 20 | 21 | type PaneView struct { | |
| 22 | + | ctx context.Context | |
| 21 | 23 | factory layout.WidgetFactory | |
| 22 | 24 | overlay layout.OverlayWidget | |
| 23 | 25 | webViewWidget layout.Widget // The actual WebView widget | |
| @@ -40,7 +42,7 @@ type PaneView struct { | |||
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 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 | 46 | overlay := factory.NewOverlay() | |
| 45 | 47 | overlay.SetHexpand(true) | |
| 46 | 48 | overlay.SetVexpand(true) | |
| @@ -81,6 +83,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg | |||
| 81 | 83 | // issues with the internal progress gizmo before the overlay is realized | |
| 82 | 84 | ||
| 83 | 85 | return &PaneView{ | |
| 86 | + | ctx: ctx, | |
| 84 | 87 | factory: factory, | |
| 85 | 88 | overlay: overlay, | |
| 86 | 89 | webViewWidget: webViewWidget, | |
| @@ -344,7 +347,16 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar { | |||
| 344 | 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 | 360 | pv.overlay.AddOverlay(pb.Widget()) | |
| 349 | 361 | pv.overlay.SetClipOverlay(pb.Widget(), false) | |
| 350 | 362 | pv.overlay.SetMeasureOverlay(pb.Widget(), false) | |
| @@ -357,6 +369,15 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar { | |||
| 357 | 369 | func (pv *PaneView) SetLoadProgress(progress float64) { | |
| 358 | 370 | pv.mu.Lock() | |
| 359 | 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 | 381 | pv.mu.Unlock() | |
| 361 | 382 | ||
| 362 | 383 | pb.SetProgress(progress) | |
| @@ -365,7 +386,18 @@ func (pv *PaneView) SetLoadProgress(progress float64) { | |||
| 365 | 386 | // SetLoading shows or hides the progress bar. | |
| 366 | 387 | func (pv *PaneView) SetLoading(loading bool) { | |
| 367 | 388 | pv.mu.Lock() | |
| 389 | + | hadProgressBar := pv.progressBar != nil | |
| 368 | 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 | 401 | pv.mu.Unlock() | |
| 370 | 402 | ||
| 371 | 403 | if loading { | |
M internal/ui/component/pane_view_test.go +24 -23
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package component_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| @@ -47,7 +48,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||
| 47 | 48 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | |
| 48 | 49 | ||
| 49 | 50 | // Act | |
| 50 | - | pv := component.NewPaneView(mockFactory, paneID, mockWebView) | |
| 51 | + | pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView) | |
| 51 | 52 | ||
| 52 | 53 | // Assert | |
| 53 | 54 | require.NotNil(t, pv) | |
| @@ -88,7 +89,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 88 | 89 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | |
| 89 | 90 | ||
| 90 | 91 | // Act | |
| 91 | - | pv := component.NewPaneView(mockFactory, paneID, nil) | |
| 92 | + | pv := component.NewPaneView(context.Background(), mockFactory, paneID, nil) | |
| 92 | 93 | ||
| 93 | 94 | // Assert | |
| 94 | 95 | require.NotNil(t, pv) | |
| @@ -104,7 +105,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) { | |||
| 104 | 105 | ||
| 105 | 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 | 110 | // Expect CSS class to be added | |
| 110 | 111 | mockBorderBox.EXPECT().AddCssClass("pane-active").Once() | |
| @@ -125,7 +126,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) { | |||
| 125 | 126 | ||
| 126 | 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 | 131 | // First activate | |
| 131 | 132 | mockBorderBox.EXPECT().AddCssClass("pane-active").Once() | |
| @@ -150,7 +151,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) { | |||
| 150 | 151 | ||
| 151 | 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 | 156 | // Act - setting false when already false should not call RemoveCssClass | |
| 156 | 157 | pv.SetActive(false) | |
| @@ -169,7 +170,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) { | |||
| 169 | 170 | paneID := entity.PaneID("test-pane-123") | |
| 170 | 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 | 175 | // Act | |
| 175 | 176 | result := pv.PaneID() | |
| @@ -187,7 +188,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) { | |||
| 187 | 188 | ||
| 188 | 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 | 193 | // Act | |
| 193 | 194 | result := pv.WebViewWidget() | |
| @@ -206,7 +207,7 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||
| 206 | 207 | ||
| 207 | 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 | 212 | // Expect removal of old widget and addition of new | |
| 212 | 213 | mockOverlay.EXPECT().SetChild(nil).Once() | |
| @@ -229,7 +230,7 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||
| 229 | 230 | ||
| 230 | 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 | 235 | // Expect only setting new child (no removal since old was nil) | |
| 235 | 236 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| @@ -251,7 +252,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) { | |||
| 251 | 252 | ||
| 252 | 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 | 257 | mockWebView.EXPECT().GrabFocus().Return(true).Once() | |
| 257 | 258 | ||
| @@ -270,7 +271,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) { | |||
| 270 | 271 | ||
| 271 | 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 | 276 | // Act | |
| 276 | 277 | result := pv.GrabFocus() | |
| @@ -288,7 +289,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) { | |||
| 288 | 289 | ||
| 289 | 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 | 294 | mockWebView.EXPECT().HasFocus().Return(true).Once() | |
| 294 | 295 | ||
| @@ -307,7 +308,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) { | |||
| 307 | 308 | ||
| 308 | 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 | 313 | // Act | |
| 313 | 314 | result := pv.HasFocus() | |
| @@ -325,7 +326,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) { | |||
| 325 | 326 | ||
| 326 | 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 | 331 | // Act | |
| 331 | 332 | result := pv.Widget() | |
| @@ -343,7 +344,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) { | |||
| 343 | 344 | ||
| 344 | 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 | 349 | // Act | |
| 349 | 350 | result := pv.Overlay() | |
| @@ -361,7 +362,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) { | |||
| 361 | 362 | ||
| 362 | 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 | 367 | mockOverlay.EXPECT().Show().Once() | |
| 367 | 368 | ||
| @@ -380,7 +381,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) { | |||
| 380 | 381 | ||
| 381 | 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 | 386 | mockOverlay.EXPECT().Hide().Once() | |
| 386 | 387 | ||
| @@ -399,7 +400,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) { | |||
| 399 | 400 | ||
| 400 | 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 | 405 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 405 | 406 | ||
| @@ -418,7 +419,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) { | |||
| 418 | 419 | ||
| 419 | 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 | 424 | mockOverlay.EXPECT().IsVisible().Return(true).Once() | |
| 424 | 425 | ||
| @@ -438,7 +439,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) { | |||
| 438 | 439 | ||
| 439 | 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 | 444 | mockOverlay.EXPECT().AddCssClass("custom-class").Once() | |
| 444 | 445 | ||
| @@ -457,7 +458,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) { | |||
| 457 | 458 | ||
| 458 | 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 | 463 | mockOverlay.EXPECT().RemoveCssClass("custom-class").Once() | |
| 463 | 464 | ||
| @@ -476,7 +477,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) { | |||
| 476 | 477 | ||
| 477 | 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 | 482 | callback := func(paneID entity.PaneID) {} | |
| 482 | 483 | ||
| @@ -496,7 +497,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) { | |||
| 496 | 497 | ||
| 497 | 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 | 502 | callback := func(paneID entity.PaneID) {} | |
| 502 | 503 | ||
M internal/ui/component/progress_bar.go +58 -1
| @@ -2,8 +2,10 @@ | |||
| 2 | 2 | package component | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 7 | 9 | "github.com/bnema/puregotk/v4/glib" | |
| 8 | 10 | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | 11 | ||
| @@ -24,6 +26,7 @@ const ( | |||
| 24 | 26 | // Implements smooth animation by incrementing towards the target value. | |
| 25 | 27 | // Includes a 30-second timeout to auto-hide if the page load stalls. | |
| 26 | 28 | type ProgressBar struct { | |
| 29 | + | ctx context.Context | |
| 27 | 30 | progressBar layout.ProgressBarWidget | |
| 28 | 31 | ||
| 29 | 32 | visible bool | |
| @@ -36,7 +39,7 @@ type ProgressBar struct { | |||
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 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 | 43 | progressBar := factory.NewProgressBar() | |
| 41 | 44 | ||
| 42 | 45 | // Add "osd" class for on-screen-display overlay styling (like Epiphany) | |
| @@ -63,6 +66,7 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar { | |||
| 63 | 66 | progressBar.SetVisible(false) | |
| 64 | 67 | ||
| 65 | 68 | return &ProgressBar{ | |
| 69 | + | ctx: ctx, | |
| 66 | 70 | progressBar: progressBar, | |
| 67 | 71 | visible: false, | |
| 68 | 72 | currentValue: 0, | |
| @@ -76,6 +80,19 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 76 | 80 | pb.mu.Lock() | |
| 77 | 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 | 96 | // Clamp progress to valid range | |
| 80 | 97 | if progress < 0 { | |
| 81 | 98 | progress = 0 | |
| @@ -131,18 +148,49 @@ func (pb *ProgressBar) startAnimation() { | |||
| 131 | 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 | 157 | // Show makes the progress bar visible and starts the auto-hide timeout. | |
| 135 | 158 | func (pb *ProgressBar) Show() { | |
| 136 | 159 | pb.mu.Lock() | |
| 137 | 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 | 173 | if !pb.visible { | |
| 140 | 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 | 182 | pb.progressBar.SetVisible(true) | |
| 142 | 183 | } | |
| 143 | 184 | ||
| 144 | 185 | // Reset timeout timer on every Show call | |
| 145 | 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 | 196 | // resetTimeout cancels any existing timeout and starts a new one. | |
| @@ -181,6 +229,15 @@ func (pb *ProgressBar) Hide() { | |||
| 181 | 229 | // hideInternal performs the actual hide operation. | |
| 182 | 230 | // Must be called with lock held. | |
| 183 | 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 | 241 | if pb.visible { | |
| 185 | 242 | pb.visible = false | |
| 186 | 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 | 87 | // Create PaneView without WebView widget for now | |
| 88 | 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 | 91 | // Store in map for later lookup | |
| 92 | 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 | 72 | try { | |
| 73 | 73 | var cssText = %s; | |
| 74 | 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 | 78 | window.__dumber_gtk_prefers_dark = prefersDark; | |
| 79 | + | window.__dumber_cef_prefers_dark = prefersDark; | |
| 78 | 80 | ||
| 79 | 81 | // Update dark/light class | |
| 80 | 82 | if (prefersDark) { | |
| 81 | - | document.documentElement.classList.add('dark'); | |
| 82 | - | document.documentElement.classList.remove('light'); | |
| 83 | + | root.classList.add('dark'); | |
| 84 | + | root.classList.remove('light'); | |
| 83 | 85 | } else { | |
| 84 | - | document.documentElement.classList.add('light'); | |
| 85 | - | document.documentElement.classList.remove('dark'); | |
| 86 | + | root.classList.add('light'); | |
| 87 | + | root.classList.remove('dark'); | |
| 86 | 88 | } | |
| 89 | + | root.style.colorScheme = prefersDark ? 'dark' : 'light'; | |
| 87 | 90 | ||
| 88 | 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 | 93 | if (!style) { | |
| 91 | 94 | style = document.createElement('style'); | |
| 95 | + | style.id = 'dumber-theme-vars'; | |
| 92 | 96 | style.setAttribute('data-dumber-theme-vars', ''); | |
| 93 | 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 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | // TestPendingReveal tests the pending reveal state management. | |
| @@ -108,6 +110,38 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | |||
| 108 | 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 | 145 | func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |
| 112 | 146 | c := &Coordinator{ | |
| 113 | 147 | pendingReveal: make(map[entity.PaneID]bool), | |
M internal/ui/coordinator/content/coordinator.go +5 -0
| @@ -14,10 +14,12 @@ import ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/ui/component" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/input" | |
| 16 | 16 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | + | "github.com/rs/zerolog" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | // Coordinator manages WebView lifecycle, title tracking, and content attachment. | |
| 20 | 21 | type Coordinator struct { | |
| 22 | + | logger zerolog.Logger | |
| 21 | 23 | pool port.WebViewPool | |
| 22 | 24 | widgetFactory layout.WidgetFactory | |
| 23 | 25 | faviconAdapter *adapter.FaviconAdapter | |
| @@ -83,6 +85,7 @@ type Coordinator struct { | |||
| 83 | 85 | factory port.WebViewFactory | |
| 84 | 86 | popupConfig *entity.PopupBehaviorConfig | |
| 85 | 87 | pendingPopups map[port.WebViewID]*PendingPopup | |
| 88 | + | namedPopups map[namedPopupKey]*namedPopupState | |
| 86 | 89 | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 87 | 90 | popupRefresh map[entity.PaneID]*time.Timer | |
| 88 | 91 | popupMu sync.RWMutex | |
| @@ -151,6 +154,7 @@ func NewCoordinator( | |||
| 151 | 154 | log.Debug().Msg("creating content coordinator") | |
| 152 | 155 | ||
| 153 | 156 | return &Coordinator{ | |
| 157 | + | logger: log.With().Str("component", "content-coordinator").Logger(), | |
| 154 | 158 | pool: pool, | |
| 155 | 159 | injector: injector, | |
| 156 | 160 | widgetFactory: widgetFactory, | |
| @@ -165,6 +169,7 @@ func NewCoordinator( | |||
| 165 | 169 | pendingThemePanes: make(map[entity.PaneID]bool), | |
| 166 | 170 | getActiveWS: getActiveWS, | |
| 167 | 171 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 172 | + | namedPopups: make(map[namedPopupKey]*namedPopupState), | |
| 168 | 173 | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 169 | 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 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 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 | 80 | c.markPendingReveal(paneID) | |
| 80 | - | if wv.EstimatedProgress() > 0 { | |
| 81 | - | c.revealIfPending(ctx, paneID, uri, "progress-after-commit") | |
| 82 | - | } | |
| 81 | + | c.revealIfPending(ctx, paneID, uri, "progress-after-commit") | |
| 83 | 82 | ||
| 84 | 83 | // Update domain model with current URI for session snapshots | |
| 85 | 84 | c.updatePaneURI(paneID, uri) | |
| @@ -188,11 +187,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | |||
| 188 | 187 | }) | |
| 189 | 188 | ||
| 190 | 189 | _, wsView := c.getActiveWS() | |
| 190 | + | var paneView *component.PaneView | |
| 191 | + | if wsView != nil { | |
| 192 | + | paneView = wsView.GetPaneView(paneID) | |
| 193 | + | } | |
| 194 | + | ||
| 191 | 195 | if wsView == nil { | |
| 192 | 196 | return | |
| 193 | 197 | } | |
| 194 | - | ||
| 195 | - | paneView := wsView.GetPaneView(paneID) | |
| 196 | 198 | if paneView != nil { | |
| 197 | 199 | paneView.SetLoading(true) | |
| 198 | 200 | } | |
| @@ -201,11 +203,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | |||
| 201 | 203 | // onLoadFinished hides the progress bar when page loading completes. | |
| 202 | 204 | func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | |
| 203 | 205 | _, wsView := c.getActiveWS() | |
| 206 | + | var paneView *component.PaneView | |
| 207 | + | if wsView != nil { | |
| 208 | + | paneView = wsView.GetPaneView(paneID) | |
| 209 | + | } | |
| 210 | + | ||
| 204 | 211 | if wsView == nil { | |
| 205 | 212 | return | |
| 206 | 213 | } | |
| 207 | - | ||
| 208 | - | paneView := wsView.GetPaneView(paneID) | |
| 209 | 214 | if paneView != nil { | |
| 210 | 215 | paneView.SetLoading(false) | |
| 211 | 216 | } | |
| @@ -225,11 +230,14 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) | |||
| 225 | 230 | } | |
| 226 | 231 | ||
| 227 | 232 | _, wsView := c.getActiveWS() | |
| 233 | + | var paneView *component.PaneView | |
| 234 | + | if wsView != nil { | |
| 235 | + | paneView = wsView.GetPaneView(paneID) | |
| 236 | + | } | |
| 237 | + | ||
| 228 | 238 | if wsView == nil { | |
| 229 | 239 | return | |
| 230 | 240 | } | |
| 231 | - | ||
| 232 | - | paneView := wsView.GetPaneView(paneID) | |
| 233 | 241 | if paneView != nil { | |
| 234 | 242 | paneView.SetLoadProgress(progress) | |
| 235 | 243 | } | |
M internal/ui/coordinator/content/popup.go +155 -13
| @@ -79,6 +79,82 @@ type PendingPopup struct { | |||
| 79 | 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 | 158 | // InsertPopupInput contains the data needed to insert a popup into the workspace. | |
| 83 | 159 | type InsertPopupInput struct { | |
| 84 | 160 | // ParentPaneID is the pane that spawned this popup. | |
| @@ -224,8 +300,12 @@ func (c *Coordinator) handlePopupCreate( | |||
| 224 | 300 | return nil | |
| 225 | 301 | } | |
| 226 | 302 | ||
| 227 | - | // Create related WebView for session sharing (created hidden) | |
| 228 | 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 | 309 | popupWV, err := c.factory.CreateRelated(ctx, parentID) | |
| 230 | 310 | if err != nil { | |
| 231 | 311 | log.Error().Err(err).Msg("failed to create related webview for popup") | |
| @@ -246,10 +326,53 @@ func (c *Coordinator) handlePopupCreate( | |||
| 246 | 326 | // Create popup pane entity | |
| 247 | 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 | 372 | hasConfig := c.popupConfig != nil | |
| 251 | 373 | oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose | |
| 252 | 374 | isOAuth := IsOAuthURL(req.TargetURI) | |
| 375 | + | ||
| 253 | 376 | log.Debug(). | |
| 254 | 377 | Bool("has_config", hasConfig). | |
| 255 | 378 | Bool("oauth_enabled", oauthEnabled). | |
| @@ -279,6 +402,7 @@ func (c *Coordinator) handlePopupCreate( | |||
| 279 | 402 | ||
| 280 | 403 | // Register WebView in our map (after successful insertion) | |
| 281 | 404 | c.setWebViewLocked(paneID, popupWV) | |
| 405 | + | c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV) | |
| 282 | 406 | ||
| 283 | 407 | // Setup standard callbacks (after successful insertion to avoid leak) | |
| 284 | 408 | c.setupWebViewCallbacks(ctx, paneID, popupWV) | |
| @@ -316,17 +440,25 @@ func (c *Coordinator) handlePopupCreate( | |||
| 316 | 440 | pc.SetOnClose(func() { | |
| 317 | 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 | 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 | 462 | return popupWV | |
| 331 | 463 | } | |
| 332 | 464 | ||
| @@ -354,12 +486,19 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W | |||
| 354 | 486 | Str("popup_type", pending.PopupType.String()). | |
| 355 | 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 | 490 | if pending.WebView != nil { | |
| 359 | 491 | if pc, ok := pending.WebView.(port.PopupCapable); ok { | |
| 360 | 492 | pc.Show() | |
| 361 | - | } else { | |
| 362 | - | log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)") | |
| 493 | + | } | |
| 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 | 530 | c.handlePopupOAuthClose(ctx, popupID) | |
| 531 | + | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 392 | 532 | c.ReleaseWebView(ctx, pending.PaneID) | |
| 393 | 533 | log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown") | |
| 394 | 534 | return | |
| @@ -407,6 +547,7 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView | |||
| 407 | 547 | ||
| 408 | 548 | if paneID == "" { | |
| 409 | 549 | c.handlePopupOAuthClose(ctx, popupID) | |
| 550 | + | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 410 | 551 | log.Warn().Msg("popup close: could not find pane for webview") | |
| 411 | 552 | return | |
| 412 | 553 | } | |
| @@ -419,6 +560,7 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView | |||
| 419 | 560 | } | |
| 420 | 561 | ||
| 421 | 562 | c.handlePopupOAuthClose(ctx, popupID) | |
| 563 | + | c.clearReusableNamedPopupByWebViewID(popupID) | |
| 422 | 564 | ||
| 423 | 565 | // Release the WebView (this will clean up tracking) | |
| 424 | 566 | c.ReleaseWebView(ctx, paneID) | |
M internal/ui/coordinator/content/popup_test.go +58 -0
| @@ -6,9 +6,11 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | 14 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| @@ -123,6 +125,62 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) { | |||
| 123 | 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 | 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 | 288 | // Check memory and disk cache (no external fetch) | |
| 289 | - | texture := c.faviconAdapter.PreloadFromCache(uri) | |
| 289 | + | texture := c.faviconAdapter.PreloadFromCache(ctx, uri) | |
| 290 | 290 | select { | |
| 291 | 291 | case <-ctx.Done(): | |
| 292 | 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 | 242 | // UpdateHistoryTitle updates the title of a history entry after page load. | |
| 243 | 243 | func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) { | |
| 244 | - | log := logging.FromContext(ctx) | |
| 245 | - | ||
| 246 | 244 | if c.navigateUC == nil { | |
| 247 | 245 | return | |
| 248 | 246 | } | |
| 249 | 247 | ||
| 250 | - | if err := c.navigateUC.UpdateHistoryTitle(ctx, url, title); err != nil { | |
| 251 | - | log.Warn().Err(err).Str("url", url).Msg("failed to update history title") | |
| 252 | - | } | |
| 248 | + | c.navigateUC.UpdateHistoryTitle(ctx, url, title) | |
| 253 | 249 | } | |
| 254 | 250 | ||
| 255 | 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 | 358 | Msg("stack split: determined split type") | |
| 359 | 359 | ||
| 360 | 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 | 362 | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 363 | 363 | ||
| 364 | 364 | // 4. Wrap the new PaneView in a StackedView | |
| @@ -534,7 +534,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 534 | 534 | Msg("determined split type") | |
| 535 | 535 | ||
| 536 | 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 | 538 | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 539 | 539 | ||
| 540 | 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 | 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 | 1442 | setupPaneViewHover(ctx, newPaneView, stackCtx.wsView) | |
| 1443 | 1443 | stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView) | |
| 1444 | 1444 | ||
| @@ -2084,7 +2084,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 2084 | 2084 | if wsView == nil { | |
| 2085 | 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 | 2088 | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 2089 | 2089 | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | |
| 2090 | 2090 | ||
M internal/ui/deps.go +6 -0
| @@ -38,6 +38,9 @@ type Dependencies struct { | |||
| 38 | 38 | // Engine (replaces individual webkit fields) | |
| 39 | 39 | Engine port.Engine | |
| 40 | 40 | ||
| 41 | + | // Handler dependencies built by bootstrap (keybindings, config save). | |
| 42 | + | HandlerDeps port.HandlerDeps | |
| 43 | + | ||
| 41 | 44 | // Repositories | |
| 42 | 45 | HistoryRepo repository.HistoryRepository | |
| 43 | 46 | FavoriteRepo repository.FavoriteRepository | |
| @@ -114,6 +117,9 @@ func (d *Dependencies) Validate() error { | |||
| 114 | 117 | if d.Engine == nil { | |
| 115 | 118 | return ErrMissingDependency("Engine") | |
| 116 | 119 | } | |
| 120 | + | if d.HandlerDeps.SaveConfig == nil { | |
| 121 | + | return ErrMissingDependency("HandlerDeps.SaveConfig") | |
| 122 | + | } | |
| 117 | 123 | // Use cases are optional - can be nil if not needed | |
| 118 | 124 | return nil | |
| 119 | 125 | } | |
M internal/ui/standalone_omnibox.go +1 -2
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 11 | "github.com/bnema/dumber/internal/ui/layout" | |
| 12 | - | "github.com/bnema/puregotk/v4/adw" | |
| 13 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | 13 | "github.com/bnema/puregotk/v4/gio" | |
| 15 | 14 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -345,7 +344,7 @@ func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRunt | |||
| 345 | 344 | return 1 | |
| 346 | 345 | } | |
| 347 | 346 | ||
| 348 | - | adw.Init() | |
| 347 | + | EnsureAdwaitaInitialized() | |
| 349 | 348 | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |
| 350 | 349 | appID := windowCfg.Namespace | |
| 351 | 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 | 442 | color: var(--muted); | |
| 443 | 443 | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | |
| 444 | 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 | 104 | assert.Contains(t, css, "font-weight: 500;") | |
| 105 | 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 | 76 | background-color: alpha(var(--bg), 0.35); | |
| 77 | 77 | background-image: none; | |
| 78 | 78 | color: var(--muted); | |
| 79 | - | text-align: left; | |
| 80 | 79 | transition: background-color 150ms ease-in-out, | |
| 81 | 80 | border-color 150ms ease-in-out, | |
| 82 | 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 | 25 | export function getWebKitBridge(): WebKitBridge | null { | |
| 24 | 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 | 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 | 57 | * Automatically: | |
| 56 | 58 | * - Converts Svelte 5 Proxy objects to plain objects | |
| @@ -62,7 +64,7 @@ export function toPlainObject<T>(obj: T): T { | |||
| 62 | 64 | export function postMessage(msg: BridgeMessage): void { | |
| 63 | 65 | const bridge = getWebKitBridge(); | |
| 64 | 66 | if (!bridge) { | |
| 65 | - | throw new Error("WebKit bridge not available"); | |
| 67 | + | throw new Error("Dumber bridge not available"); | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 68 | 70 | // Ensure webviewId is set | |
M webui/src/pages/ConfigPage.svelte +81 -7
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { onMount } from "svelte"; | |
| 3 | - | import { ModeWatcher } from "mode-watcher"; | |
| 3 | + | import { ModeWatcher, resetMode, setMode } from "mode-watcher"; | |
| 4 | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 5 | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |
| 6 | 6 | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |
| @@ -83,6 +83,7 @@ | |||
| 83 | 83 | default_ui_scale: number; | |
| 84 | 84 | default_search_engine: string; | |
| 85 | 85 | search_shortcuts: Record<string, SearchShortcut>; | |
| 86 | + | engine_type: string; | |
| 86 | 87 | }; | |
| 87 | 88 | ||
| 88 | 89 | const PERFORMANCE_PROFILES = [ | |
| @@ -117,6 +118,49 @@ | |||
| 117 | 118 | let themeEvents = $state(0); | |
| 118 | 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 | 164 | // Load config from API | |
| 121 | 165 | async function loadConfig() { | |
| 122 | 166 | loading = true; | |
| @@ -125,6 +169,10 @@ | |||
| 125 | 169 | const response = await fetch("/api/config"); | |
| 126 | 170 | if (!response.ok) throw new Error("Failed to fetch config"); | |
| 127 | 171 | config = (await response.json()) as ConfigDTO; | |
| 172 | + | syncModeWatcherPreference(config.appearance.color_scheme); | |
| 173 | + | if (!isPerformanceSupported(config) && activeTab === "performance") { | |
| 174 | + | activeTab = "appearance"; | |
| 175 | + | } | |
| 128 | 176 | } catch (e: unknown) { | |
| 129 | 177 | loadError = getErrorMessage(e); | |
| 130 | 178 | console.error("[config] load failed", e); | |
| @@ -146,6 +194,10 @@ | |||
| 146 | 194 | const response = await fetch("/api/config/default"); | |
| 147 | 195 | if (!response.ok) throw new Error("Failed to fetch default config"); | |
| 148 | 196 | config = (await response.json()) as ConfigDTO; | |
| 197 | + | syncModeWatcherPreference(config.appearance.color_scheme); | |
| 198 | + | if (!isPerformanceSupported(config) && activeTab === "performance") { | |
| 199 | + | activeTab = "appearance"; | |
| 200 | + | } | |
| 149 | 201 | } catch (e: unknown) { | |
| 150 | 202 | console.error("[config] reset defaults failed", e); | |
| 151 | 203 | } | |
| @@ -153,7 +205,7 @@ | |||
| 153 | 205 | } | |
| 154 | 206 | ||
| 155 | 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 | 209 | if (bridge && typeof bridge.postMessage === "function") { | |
| 158 | 210 | return bridge; | |
| 159 | 211 | } | |
| @@ -180,7 +232,7 @@ | |||
| 180 | 232 | }); | |
| 181 | 233 | ||
| 182 | 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 | 237 | if (!config) { | |
| 186 | 238 | throw new Error("Config not loaded"); | |
| @@ -208,10 +260,12 @@ | |||
| 208 | 260 | saving = false; | |
| 209 | 261 | ||
| 210 | 262 | // Show restart warning if performance tab was active | |
| 211 | - | if (activeTab === "performance") { | |
| 263 | + | if (activeTab === "performance" && isPerformanceSupported(config)) { | |
| 212 | 264 | showRestartWarning = true; | |
| 213 | 265 | } | |
| 214 | 266 | ||
| 267 | + | syncModeWatcherPreference(config?.appearance.color_scheme); | |
| 268 | + | ||
| 215 | 269 | // Refresh config from backend (in case watcher normalized values) | |
| 216 | 270 | reloadConfig(); | |
| 217 | 271 | ||
| @@ -253,12 +307,14 @@ | |||
| 253 | 307 | onMount(() => { | |
| 254 | 308 | console.debug("[config] mount", { | |
| 255 | 309 | webviewId: window.__dumber_webview_id, | |
| 256 | - | hasBridge: Boolean(window.webkit?.messageHandlers?.dumber), | |
| 310 | + | hasBridge: Boolean(getWebKitBridge()), | |
| 257 | 311 | }); | |
| 258 | 312 | ||
| 259 | 313 | const onThemeChanged = (e: Event) => { | |
| 260 | 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 | 319 | window.addEventListener("dumber:theme-changed", onThemeChanged); | |
| 264 | 320 | ||
| @@ -305,10 +361,22 @@ | |||
| 305 | 361 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |
| 306 | 362 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |
| 307 | 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 | 371 | </Tabs.List> | |
| 310 | 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 | 380 | <Tabs.Content value="appearance"> | |
| 313 | 381 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 314 | 382 | <Card.Header> | |
| @@ -449,6 +517,11 @@ | |||
| 449 | 517 | </Card.Description> | |
| 450 | 518 | </Card.Header> | |
| 451 | 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 | 525 | <!-- Detected Hardware Card --> | |
| 453 | 526 | {#if config.performance?.hardware} | |
| 454 | 527 | {@const hw = config.performance.hardware} | |
| @@ -614,6 +687,7 @@ | |||
| 614 | 687 | </div> | |
| 615 | 688 | </div> | |
| 616 | 689 | {/if} | |
| 690 | + | {/if} | |
| 617 | 691 | </Card.Content> | |
| 618 | 692 | </Card.Root> | |
| 619 | 693 | </Tabs.Content> | |
M webui/src/pages/config/KeybindingsTab.svelte +2 -2
| @@ -54,7 +54,7 @@ | |||
| 54 | 54 | error = null; | |
| 55 | 55 | ||
| 56 | 56 | if (!getWebKitBridge()) { | |
| 57 | - | error = "WebKit bridge not available"; | |
| 57 | + | error = "Dumber bridge not available"; | |
| 58 | 58 | loading = false; | |
| 59 | 59 | return; | |
| 60 | 60 | } | |
| @@ -93,7 +93,7 @@ | |||
| 93 | 93 | if (!editingBinding) return; | |
| 94 | 94 | ||
| 95 | 95 | if (!getWebKitBridge()) { | |
| 96 | - | error = "WebKit bridge not available"; | |
| 96 | + | error = "Dumber bridge not available"; | |
| 97 | 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 | |
| 3 | - | // Communication layer between frontend and Go backend via WebKit message handlers | |
| 2 | + | // Homepage bridge | |
| 3 | + | // Communication layer between frontend and Go backend via WebKit or CEF bridge. | |
| 4 | 4 | // ═══════════════════════════════════════════════════════════════════════════════ | |
| 5 | 5 | ||
| 6 | 6 | import type { | |
| @@ -43,7 +43,7 @@ let callbacksInitialized = false; | |||
| 43 | 43 | // ═══════════════════════════════════════════════════════════════════════════════ | |
| 44 | 44 | ||
| 45 | 45 | function getWebKitBridge(): DumberBridge | null { | |
| 46 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 46 | + | const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber; | |
| 47 | 47 | if (bridge && typeof bridge.postMessage === 'function') { | |
| 48 | 48 | return bridge; | |
| 49 | 49 | } | |
| @@ -178,7 +178,7 @@ async function sendMessage<T = unknown>( | |||
| 178 | 178 | ||
| 179 | 179 | const bridge = getWebKitBridge(); | |
| 180 | 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 | 184 | const requestId = generateRequestId(type); | |
M webui/src/types/dumber.d.ts +6 -0
| @@ -77,6 +77,8 @@ declare global { | |||
| 77 | 77 | __dumber_webview_id?: number; | |
| 78 | 78 | /** WebKit message handlers for native bridge */ | |
| 79 | 79 | webkit?: DumberWebKit; | |
| 80 | + | /** CEF bridge shim injected on internal pages */ | |
| 81 | + | dumber?: DumberBridge; | |
| 80 | 82 | ||
| 81 | 83 | // ───────────────────────────────────────────────────────────────────────── | |
| 82 | 84 | // Config Page Callbacks | |
| @@ -139,6 +141,10 @@ declare global { | |||
| 139 | 141 | __dumber_color_scheme_manager?: ColorSchemeManager; | |
| 140 | 142 | /** Legacy theme setter */ | |
| 141 | 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 | 150 | interface Document { | |
M webui/vite-plugin-pages.ts +46 -1
| @@ -15,6 +15,49 @@ interface PageConfig { | |||
| 15 | 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 | 61 | // Load the favicon SVG from assets | |
| 19 | 62 | function getFaviconSVG(): string { | |
| 20 | 63 | const logoPath = resolve(__dirname, "..", "assets", "logo.svg"); | |
| @@ -26,6 +69,7 @@ function generatePageHTML(config: PageConfig): string { | |||
| 26 | 69 | const cssLink = config.css | |
| 27 | 70 | ? `<link rel="stylesheet" href="./${config.css}">` | |
| 28 | 71 | : ""; | |
| 72 | + | const bridgeBootstrap = getBridgeBootstrap(); | |
| 29 | 73 | ||
| 30 | 74 | return `<!DOCTYPE html> | |
| 31 | 75 | <html lang="en"> | |
| @@ -33,7 +77,8 @@ function generatePageHTML(config: PageConfig): string { | |||
| 33 | 77 | <meta charset="UTF-8"> | |
| 34 | 78 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 35 | 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 | 82 | ${cssLink} | |
| 38 | 83 | </head> | |
| 39 | 84 | <body> | |