detached ef69324

Merge pull request #225 from bnema/fix/cef-clipboard-input-focus

fix: harden CEF startup and remove helper binary
commit
ef6932462fe35f1c3906476e0cee329094b41726
author
bnema · · 2026-04-19 10:28 UTC
committer
GitHub · · 2026-04-19 10:28 UTC
merge of
34487b0 + 687083c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +475 -147
M .gitignore +0 -1
@@ -8,7 +8,6 @@ dist/
88
99 # Binaries for programs and plugins
1010 /dumber
11-/cef-helper
1211 dumber.exe
1312
1413 # Test binary, built with `go test -c`
@@ -8,7 +8,6 @@ dist/
8 8
9 # Binaries for programs and plugins9 # Binaries for programs and plugins
10 /dumber10 /dumber
11-/cef-helper
12 dumber.exe11 dumber.exe
13 12
14 # Test binary, built with `go test -c`13 # Test binary, built with `go test -c`
M Makefile +17 -3
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
3+.PHONY: build build-frontend build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -12,6 +12,7 @@ endif
1212 BINARY_NAME=dumber
1313 MAIN_PATH=./cmd/dumber
1414 DIST_DIR=dist
15+LOCAL_BIN_DIR?=$(HOME)/.local/bin
1516
1617 # Detect number of CPU cores for parallel compilation
1718 NPROCS?=$(shell nproc 2>/dev/null || echo 1)
@@ -42,7 +43,7 @@ build: build-frontend ## Build the application (pure Go, no CGO)
4243 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4344 @mkdir -p $(DIST_DIR)
4445 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
46+ @rm -f $(DIST_DIR)/cef-helper
4647 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4748
4849 build-frontend: ## Build homepage and error pages
@@ -54,9 +55,22 @@ build-quick: ## Build without frontend (faster for backend development)
5455 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
5556 @mkdir -p $(DIST_DIR)
5657 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
58+ @rm -f $(DIST_DIR)/cef-helper
5859 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
5960
61+install-local: build-quick ## Install dumber to ~/.local/bin atomically
62+ @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..."
63+ @mkdir -p $(LOCAL_BIN_DIR)
64+ @tmp_dumber="$$(mktemp '$(LOCAL_BIN_DIR)/.dumber.tmp.XXXXXX')"; \
65+ trap 'rm -f "$$tmp_dumber"' EXIT INT TERM; \
66+ install -m 0755 $(DIST_DIR)/$(BINARY_NAME) "$$tmp_dumber"; \
67+ mv -f "$$tmp_dumber" $(LOCAL_BIN_DIR)/$(BINARY_NAME); \
68+ removed_stale=0; \
69+ if [ -e $(LOCAL_BIN_DIR)/cef-helper ]; then rm -f $(LOCAL_BIN_DIR)/cef-helper && removed_stale=1; fi; \
70+ trap - EXIT INT TERM; \
71+ echo "Installed: $(LOCAL_BIN_DIR)/$(BINARY_NAME)"; \
72+ if [ "$$removed_stale" -eq 1 ]; then echo "Removed stale: $(LOCAL_BIN_DIR)/cef-helper"; fi
73+
6074 # Development targets
6175 dev: ## Run in development mode
6276 @echo "Running in development mode..."
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check3+.PHONY: build build-frontend build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -12,6 +12,7 @@ endif
12 BINARY_NAME=dumber12 BINARY_NAME=dumber
13 MAIN_PATH=./cmd/dumber13 MAIN_PATH=./cmd/dumber
14 DIST_DIR=dist14 DIST_DIR=dist
15+LOCAL_BIN_DIR?=$(HOME)/.local/bin
15 16
16 # Detect number of CPU cores for parallel compilation17 # Detect number of CPU cores for parallel compilation
17 NPROCS?=$(shell nproc 2>/dev/null || echo 1)18 NPROCS?=$(shell nproc 2>/dev/null || echo 1)
@@ -42,7 +43,7 @@ build: build-frontend ## Build the application (pure Go, no CGO)
42 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."43 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
43 @mkdir -p $(DIST_DIR)44 @mkdir -p $(DIST_DIR)
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) $(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-helper46+ @rm -f $(DIST_DIR)/cef-helper
46 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
47 48
48 build-frontend: ## Build homepage and error pages49 build-frontend: ## Build homepage and error pages
@@ -54,9 +55,22 @@ build-quick: ## Build without frontend (faster for backend development)
54 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."55 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
55 @mkdir -p $(DIST_DIR)56 @mkdir -p $(DIST_DIR)
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) $(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-helper58+ @rm -f $(DIST_DIR)/cef-helper
58 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"59 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
59 60
61+install-local: build-quick ## Install dumber to ~/.local/bin atomically
62+ @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..."
63+ @mkdir -p $(LOCAL_BIN_DIR)
64+ @tmp_dumber="$$(mktemp '$(LOCAL_BIN_DIR)/.dumber.tmp.XXXXXX')"; \
65+ trap 'rm -f "$$tmp_dumber"' EXIT INT TERM; \
66+ install -m 0755 $(DIST_DIR)/$(BINARY_NAME) "$$tmp_dumber"; \
67+ mv -f "$$tmp_dumber" $(LOCAL_BIN_DIR)/$(BINARY_NAME); \
68+ removed_stale=0; \
69+ if [ -e $(LOCAL_BIN_DIR)/cef-helper ]; then rm -f $(LOCAL_BIN_DIR)/cef-helper && removed_stale=1; fi; \
70+ trap - EXIT INT TERM; \
71+ echo "Installed: $(LOCAL_BIN_DIR)/$(BINARY_NAME)"; \
72+ if [ "$$removed_stale" -eq 1 ]; then echo "Removed stale: $(LOCAL_BIN_DIR)/cef-helper"; fi
73+
60 # Development targets74 # Development targets
61 dev: ## Run in development mode75 dev: ## Run in development mode
62 @echo "Running in development mode..."76 @echo "Running in development mode..."
D cmd/cef-helper/main.go +0 -33
@@ -1,33 +0,0 @@
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- infracef "github.com/bnema/dumber/internal/infrastructure/cef"
17- cef "github.com/bnema/purego-cef/cef"
18-)
19-
20-func main() {
21- // Pin to the main OS thread — CEF requires it.
22- runtime.LockOSThread()
23-
24- // cef_execute_process will handle the subprocess lifecycle and call
25- // os.Exit internally if this is a valid CEF subprocess invocation.
26- // We pass the lightweight subprocess app, which intentionally returns nil
27- // from GetRenderProcessHandler(), so renderer-side bridges remain disabled
28- // in helper subprocesses while the OSR startup regression is under repair.
29- cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
30-
31- // If we get here, something went wrong.
32- os.Exit(1)
33-}
@@ -1,33 +0,0 @@
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- infracef "github.com/bnema/dumber/internal/infrastructure/cef"
17- cef "github.com/bnema/purego-cef/cef"
18-)
19-
20-func main() {
21- // Pin to the main OS thread — CEF requires it.
22- runtime.LockOSThread()
23-
24- // cef_execute_process will handle the subprocess lifecycle and call
25- // os.Exit internally if this is a valid CEF subprocess invocation.
26- // We pass the lightweight subprocess app, which intentionally returns nil
27- // from GetRenderProcessHandler(), so renderer-side bridges remain disabled
28- // in helper subprocesses while the OSR startup regression is under repair.
29- cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
30-
31- // If we get here, something went wrong.
32- os.Exit(1)
33-}
M cmd/dumber/main.go +23 -11
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/domain/entity"
1919 "github.com/bnema/dumber/internal/domain/repository"
2020 "github.com/bnema/dumber/internal/infrastructure/cache"
21+ infracef "github.com/bnema/dumber/internal/infrastructure/cef"
2122 "github.com/bnema/dumber/internal/infrastructure/clipboard"
2223 "github.com/bnema/dumber/internal/infrastructure/config"
2324 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -117,19 +118,20 @@ func configureBrowserLaunchRelay(cfg *config.Config) {
117118 }
118119
119120 func main() {
120- enableCrashForensics()
121-
122- // CEF subprocess handling: when CEF re-launches the binary with
121+ // CEF subprocess handling: when CEF re-launches this binary with
123122 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
124123 // else (Cobra, config, arg stripping). We detect subprocesses by the
125- // presence of --type= in os.Args rather than checking the engine env
126- // var, because subprocesses may not inherit the environment and the
124+ // presence of a --type flag in os.Args rather than checking the engine
125+ // env var, because subprocesses may not inherit the environment and the
127126 // engine can also be selected via config.
128- if isCEFSubprocess() {
129- cef.MaybeExitSubprocess()
127+ if isCEFSubprocess(os.Args) {
128+ runtime.LockOSThread()
129+ cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
130130 os.Exit(1)
131131 }
132132
133+ enableCrashForensics()
134+
133135 mode, browseURL := launchModeFromArgs(os.Args)
134136 // Run GUI mode for browse command
135137 if mode == launchModeBrowse {
@@ -727,12 +729,22 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
727729 }
728730 }
729731
730-// isCEFSubprocess returns true if os.Args contains a --type= flag, indicating
732+// isCEFSubprocess returns true if args contains a --type flag, indicating
731733 // this process was spawned by CEF as a renderer, GPU, or utility subprocess.
732-func isCEFSubprocess() bool {
733- for _, arg := range os.Args {
734+func isCEFSubprocess(args []string) bool {
735+ for i, arg := range args {
734736 if strings.HasPrefix(arg, "--type=") {
735- return true
737+ value := strings.TrimPrefix(arg, "--type=")
738+ if value != "" && !strings.HasPrefix(value, "-") {
739+ return true
740+ }
741+ continue
742+ }
743+ if arg == "--type" && i+1 < len(args) {
744+ next := args[i+1]
745+ if next != "" && !strings.HasPrefix(next, "-") {
746+ return true
747+ }
736748 }
737749 }
738750 return false
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/dumber/internal/domain/entity"18 "github.com/bnema/dumber/internal/domain/entity"
19 "github.com/bnema/dumber/internal/domain/repository"19 "github.com/bnema/dumber/internal/domain/repository"
20 "github.com/bnema/dumber/internal/infrastructure/cache"20 "github.com/bnema/dumber/internal/infrastructure/cache"
21+ infracef "github.com/bnema/dumber/internal/infrastructure/cef"
21 "github.com/bnema/dumber/internal/infrastructure/clipboard"22 "github.com/bnema/dumber/internal/infrastructure/clipboard"
22 "github.com/bnema/dumber/internal/infrastructure/config"23 "github.com/bnema/dumber/internal/infrastructure/config"
23 "github.com/bnema/dumber/internal/infrastructure/deps"24 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -117,19 +118,20 @@ func configureBrowserLaunchRelay(cfg *config.Config) {
117 }118 }
118 119
119 func main() {120 func main() {
120- enableCrashForensics()121+ // CEF subprocess handling: when CEF re-launches this binary with
121-
122- // CEF subprocess handling: when CEF re-launches the binary with
123 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything122 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
124 // else (Cobra, config, arg stripping). We detect subprocesses by the123 // else (Cobra, config, arg stripping). We detect subprocesses by the
125- // presence of --type= in os.Args rather than checking the engine env124+ // presence of a --type flag in os.Args rather than checking the engine
126- // var, because subprocesses may not inherit the environment and the125+ // env var, because subprocesses may not inherit the environment and the
127 // engine can also be selected via config.126 // engine can also be selected via config.
128- if isCEFSubprocess() {127+ if isCEFSubprocess(os.Args) {
129- cef.MaybeExitSubprocess()128+ runtime.LockOSThread()
129+ cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
130 os.Exit(1)130 os.Exit(1)
131 }131 }
132 132
133+ enableCrashForensics()
134+
133 mode, browseURL := launchModeFromArgs(os.Args)135 mode, browseURL := launchModeFromArgs(os.Args)
134 // Run GUI mode for browse command136 // Run GUI mode for browse command
135 if mode == launchModeBrowse {137 if mode == launchModeBrowse {
@@ -727,12 +729,22 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
727 }729 }
728 }730 }
729 731
730-// isCEFSubprocess returns true if os.Args contains a --type= flag, indicating732+// isCEFSubprocess returns true if args contains a --type flag, indicating
731 // this process was spawned by CEF as a renderer, GPU, or utility subprocess.733 // this process was spawned by CEF as a renderer, GPU, or utility subprocess.
732-func isCEFSubprocess() bool {734+func isCEFSubprocess(args []string) bool {
733- for _, arg := range os.Args {735+ for i, arg := range args {
734 if strings.HasPrefix(arg, "--type=") {736 if strings.HasPrefix(arg, "--type=") {
735- return true737+ value := strings.TrimPrefix(arg, "--type=")
738+ if value != "" && !strings.HasPrefix(value, "-") {
739+ return true
740+ }
741+ continue
742+ }
743+ if arg == "--type" && i+1 < len(args) {
744+ next := args[i+1]
745+ if next != "" && !strings.HasPrefix(next, "-") {
746+ return true
747+ }
736 }748 }
737 }749 }
738 return false750 return false
M cmd/dumber/main_test.go +47 -0
@@ -72,6 +72,53 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
7272 }
7373 }
7474
75+func TestIsCEFSubprocess(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ args []string
79+ want bool
80+ }{
81+ {
82+ name: "equals form",
83+ args: []string{"dumber", "--type=renderer"},
84+ want: true,
85+ },
86+ {
87+ name: "separate value form",
88+ args: []string{"dumber", "--type", "renderer"},
89+ want: true,
90+ },
91+ {
92+ name: "equals form with empty value",
93+ args: []string{"dumber", "--type="},
94+ want: false,
95+ },
96+ {
97+ name: "bare type",
98+ args: []string{"dumber", "--type"},
99+ want: false,
100+ },
101+ {
102+ name: "type followed by flag",
103+ args: []string{"dumber", "--type", "--unexpected"},
104+ want: false,
105+ },
106+ {
107+ name: "no type flag",
108+ args: []string{"dumber", "--unexpected"},
109+ want: false,
110+ },
111+ }
112+
113+ for _, tc := range tests {
114+ t.Run(tc.name, func(t *testing.T) {
115+ if got := isCEFSubprocess(tc.args); got != tc.want {
116+ t.Fatalf("expected %v, got %v", tc.want, got)
117+ }
118+ })
119+ }
120+}
121+
75122 func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
76123 got, err := resolveCurrentExecutable(func() (string, error) {
77124 return "/usr/bin/dumber", nil
@@ -72,6 +72,53 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
72 }72 }
73 }73 }
74 74
75+func TestIsCEFSubprocess(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ args []string
79+ want bool
80+ }{
81+ {
82+ name: "equals form",
83+ args: []string{"dumber", "--type=renderer"},
84+ want: true,
85+ },
86+ {
87+ name: "separate value form",
88+ args: []string{"dumber", "--type", "renderer"},
89+ want: true,
90+ },
91+ {
92+ name: "equals form with empty value",
93+ args: []string{"dumber", "--type="},
94+ want: false,
95+ },
96+ {
97+ name: "bare type",
98+ args: []string{"dumber", "--type"},
99+ want: false,
100+ },
101+ {
102+ name: "type followed by flag",
103+ args: []string{"dumber", "--type", "--unexpected"},
104+ want: false,
105+ },
106+ {
107+ name: "no type flag",
108+ args: []string{"dumber", "--unexpected"},
109+ want: false,
110+ },
111+ }
112+
113+ for _, tc := range tests {
114+ t.Run(tc.name, func(t *testing.T) {
115+ if got := isCEFSubprocess(tc.args); got != tc.want {
116+ t.Fatalf("expected %v, got %v", tc.want, got)
117+ }
118+ })
119+ }
120+}
121+
75 func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {122 func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
76 got, err := resolveCurrentExecutable(func() (string, error) {123 got, err := resolveCurrentExecutable(func() (string, error) {
77 return "/usr/bin/dumber", nil124 return "/usr/bin/dumber", nil
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.26.1
44
55 require (
66 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.9.0
7+ github.com/bnema/purego-cef v0.9.1
88 github.com/bnema/purego-ffmpeg v0.3.1
99 github.com/bnema/purego-pipewire v0.1.2
1010 github.com/bnema/purego-sqlite v0.1.2
@@ -4,7 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.26 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.9.07+ github.com/bnema/purego-cef v0.9.1
8 github.com/bnema/purego-ffmpeg v0.3.18 github.com/bnema/purego-ffmpeg v0.3.1
9 github.com/bnema/purego-pipewire v0.1.29 github.com/bnema/purego-pipewire v0.1.2
10 github.com/bnema/purego-sqlite v0.1.210 github.com/bnema/purego-sqlite v0.1.2
M go.sum +2 -0 generated file: collapsed
M internal/bootstrap/engine.go +4 -1
@@ -106,7 +106,10 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
106106 },
107107 }
108108 audioFactory := audiofactory.NewAudioOutputFactory()
109- return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps)
109+ return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
110+ StateRoot: profile.CEFUserDataDir(),
111+ LogFile: profile.CEFLogFile(),
112+ }, cefCfg, transcodingCfg, audioFactory, deps)
110113 default:
111114 return nil, fmt.Errorf("unknown engine type: %q", engineType)
112115 }
@@ -106,7 +106,10 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
106 },106 },
107 }107 }
108 audioFactory := audiofactory.NewAudioOutputFactory()108 audioFactory := audiofactory.NewAudioOutputFactory()
109- return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps)109+ return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
110+ StateRoot: profile.CEFUserDataDir(),
111+ LogFile: profile.CEFLogFile(),
112+ }, cefCfg, transcodingCfg, audioFactory, deps)
110 default:113 default:
111 return nil, fmt.Errorf("unknown engine type: %q", engineType)114 return nil, fmt.Errorf("unknown engine type: %q", engineType)
112 }115 }
M internal/infrastructure/cef/app.go +12 -4
@@ -56,10 +56,10 @@ func newDumberApp(engine *Engine) purecef.App {
5656 return app
5757 }
5858
59-// NewSubprocessApp returns a lightweight raw App implementation for helper
60-// processes so cef_execute_process can wrap it itself. Keep helper processes
61-// free of custom render handlers for now: the renderer bridge introduced a
62-// startup regression where OSR pages stopped producing their first paint.
59+// NewSubprocessApp returns a lightweight raw App implementation for CEF
60+// subprocesses re-executed from the main dumber binary. Keep subprocesses free
61+// of custom render handlers for now: the renderer bridge introduced a startup
62+// regression where OSR pages stopped producing their first paint.
6363 func NewSubprocessApp() purecef.App {
6464 return &subprocessApp{}
6565 }
@@ -122,6 +122,7 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
122122 useAngle := ""
123123 ozonePlatform := ""
124124 if commandLine != nil {
125+ appendSwitchIfMissing(commandLine, "no-zygote")
125126 processType = commandLine.GetSwitchValue("type")
126127 commandLineString = commandLine.GetCommandLineString()
127128 useAngle = commandLine.GetSwitchValue("use-angle")
@@ -130,6 +131,13 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
130131 h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
131132 }
132133
134+func appendSwitchIfMissing(commandLine purecef.CommandLine, name string) {
135+ if commandLine == nil || commandLine.HasSwitch(name) {
136+ return
137+ }
138+ commandLine.AppendSwitch(name)
139+}
140+
133141 func parseRelaunchCommandLineArgs(commandLine string) []string {
134142 args := make([]string, 0, 4)
135143 var current strings.Builder
@@ -56,10 +56,10 @@ func newDumberApp(engine *Engine) purecef.App {
56 return app56 return app
57 }57 }
58 58
59-// NewSubprocessApp returns a lightweight raw App implementation for helper59+// NewSubprocessApp returns a lightweight raw App implementation for CEF
60-// processes so cef_execute_process can wrap it itself. Keep helper processes60+// subprocesses re-executed from the main dumber binary. Keep subprocesses free
61-// free of custom render handlers for now: the renderer bridge introduced a61+// of custom render handlers for now: the renderer bridge introduced a startup
62-// startup regression where OSR pages stopped producing their first paint.62+// regression where OSR pages stopped producing their first paint.
63 func NewSubprocessApp() purecef.App {63 func NewSubprocessApp() purecef.App {
64 return &subprocessApp{}64 return &subprocessApp{}
65 }65 }
@@ -122,6 +122,7 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
122 useAngle := ""122 useAngle := ""
123 ozonePlatform := ""123 ozonePlatform := ""
124 if commandLine != nil {124 if commandLine != nil {
125+ appendSwitchIfMissing(commandLine, "no-zygote")
125 processType = commandLine.GetSwitchValue("type")126 processType = commandLine.GetSwitchValue("type")
126 commandLineString = commandLine.GetCommandLineString()127 commandLineString = commandLine.GetCommandLineString()
127 useAngle = commandLine.GetSwitchValue("use-angle")128 useAngle = commandLine.GetSwitchValue("use-angle")
@@ -130,6 +131,13 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
130 h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)131 h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
131 }132 }
132 133
134+func appendSwitchIfMissing(commandLine purecef.CommandLine, name string) {
135+ if commandLine == nil || commandLine.HasSwitch(name) {
136+ return
137+ }
138+ commandLine.AppendSwitch(name)
139+}
140+
133 func parseRelaunchCommandLineArgs(commandLine string) []string {141 func parseRelaunchCommandLineArgs(commandLine string) []string {
134 args := make([]string, 0, 4)142 args := make([]string, 0, 4)
135 var current strings.Builder143 var current strings.Builder
M internal/infrastructure/cef/app_test.go +78 -21
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56 "unsafe"
67
@@ -24,27 +25,56 @@ type relaunchCommandLineStub struct {
2425 commandLineString string
2526 }
2627
27-func (c relaunchCommandLineStub) IsValid() bool { return true }
28-func (c relaunchCommandLineStub) IsReadOnly() bool { return true }
29-func (c relaunchCommandLineStub) Copy() purecef.CommandLine { return c }
30-func (c relaunchCommandLineStub) InitFromArgv(int32, unsafe.Pointer) {}
31-func (c relaunchCommandLineStub) InitFromString(string) {}
32-func (c relaunchCommandLineStub) Reset() {}
33-func (c relaunchCommandLineStub) GetArgv(uintptr) {}
34-func (c relaunchCommandLineStub) GetCommandLineString() string { return c.commandLineString }
35-func (c relaunchCommandLineStub) GetProgram() string { return "dumber" }
36-func (c relaunchCommandLineStub) SetProgram(string) {}
37-func (c relaunchCommandLineStub) HasSwitches() bool { return false }
38-func (c relaunchCommandLineStub) HasSwitch(string) bool { return false }
39-func (c relaunchCommandLineStub) GetSwitchValue(string) string { return "" }
40-func (c relaunchCommandLineStub) GetSwitches(uintptr) {}
41-func (c relaunchCommandLineStub) AppendSwitch(string) {}
42-func (c relaunchCommandLineStub) AppendSwitchWithValue(string, string) {}
43-func (c relaunchCommandLineStub) HasArguments() bool { return true }
44-func (c relaunchCommandLineStub) GetArguments(uintptr) {}
45-func (c relaunchCommandLineStub) AppendArgument(string) {}
46-func (c relaunchCommandLineStub) PrependWrapper(string) {}
47-func (c relaunchCommandLineStub) RemoveSwitch(string) {}
28+func (c relaunchCommandLineStub) IsValid() bool { return true }
29+func (c relaunchCommandLineStub) IsReadOnly() bool { return true }
30+func (c relaunchCommandLineStub) Copy() purecef.CommandLine { return c }
31+func (c relaunchCommandLineStub) InitFromArgv(int32, unsafe.Pointer) {}
32+func (c relaunchCommandLineStub) InitFromString(string) {}
33+func (c relaunchCommandLineStub) Reset() {}
34+func (c relaunchCommandLineStub) GetArgv(uintptr) {}
35+func (c relaunchCommandLineStub) GetCommandLineString() string { return c.commandLineString }
36+func (c relaunchCommandLineStub) GetProgram() string { return "dumber" }
37+func (c relaunchCommandLineStub) SetProgram(string) {}
38+func (c relaunchCommandLineStub) HasSwitches() bool { return false }
39+func (c relaunchCommandLineStub) HasSwitch(string) bool { return false }
40+func (c relaunchCommandLineStub) GetSwitchValue(string) string { return "" }
41+func (c relaunchCommandLineStub) GetSwitches(uintptr) {}
42+func (c relaunchCommandLineStub) AppendSwitch(string) {}
43+func (c relaunchCommandLineStub) AppendSwitchWithValue(string, string) {
44+}
45+func (c relaunchCommandLineStub) HasArguments() bool { return true }
46+func (c relaunchCommandLineStub) GetArguments(uintptr) {}
47+func (c relaunchCommandLineStub) AppendArgument(string) {
48+}
49+func (c relaunchCommandLineStub) PrependWrapper(string) {}
50+func (c relaunchCommandLineStub) RemoveSwitch(string) {}
51+
52+type mutableCommandLineStub struct {
53+ relaunchCommandLineStub
54+ switches map[string]string
55+}
56+
57+func newMutableCommandLineStub() *mutableCommandLineStub {
58+ return &mutableCommandLineStub{switches: make(map[string]string)}
59+}
60+
61+func (c *mutableCommandLineStub) IsReadOnly() bool { return false }
62+func (c *mutableCommandLineStub) Copy() purecef.CommandLine { return c }
63+func (c *mutableCommandLineStub) Reset() { c.switches = make(map[string]string) }
64+func (c *mutableCommandLineStub) HasSwitches() bool { return len(c.switches) > 0 }
65+func (c *mutableCommandLineStub) HasSwitch(name string) bool {
66+ _, ok := c.switches[name]
67+ return ok
68+}
69+func (c *mutableCommandLineStub) GetSwitchValue(name string) string { return c.switches[name] }
70+func (c *mutableCommandLineStub) AppendSwitch(name string) { c.switches[name] = "" }
71+func (c *mutableCommandLineStub) AppendSwitchWithValue(name, value string) {
72+ c.switches[name] = value
73+}
74+func (c *mutableCommandLineStub) HasArguments() bool { return false }
75+func (c *mutableCommandLineStub) RemoveSwitch(name string) {
76+ delete(c.switches, name)
77+}
4878
4979 func TestRegisterDumbScheme(t *testing.T) {
5080 t.Parallel()
@@ -143,3 +173,30 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardNonBrowse(t *testin
143173 t.Fatal("handler should not be called for non-browse relaunch")
144174 }
145175 }
176+
177+func TestConfigureCommandLine_AppendsExpectedSwitches(t *testing.T) {
178+ t.Parallel()
179+
180+ commandLine := newMutableCommandLineStub()
181+ configureCommandLine(commandLine)
182+
183+ if !commandLine.HasSwitch("enable-smooth-scrolling") {
184+ t.Fatal("expected enable-smooth-scrolling switch to be appended")
185+ }
186+ if got := commandLine.GetSwitchValue("autoplay-policy"); got != "no-user-gesture-required" {
187+ t.Fatalf("autoplay-policy = %q, want %q", got, "no-user-gesture-required")
188+ }
189+}
190+
191+func TestDumberBPH_OnBeforeChildProcessLaunch_AppendsNoZygote(t *testing.T) {
192+ t.Parallel()
193+
194+ commandLine := newMutableCommandLineStub()
195+ commandLine.AppendSwitchWithValue("type", "renderer")
196+
197+ (&dumberBPH{engine: &Engine{ctx: context.Background()}}).OnBeforeChildProcessLaunch(commandLine)
198+
199+ if !commandLine.HasSwitch("no-zygote") {
200+ t.Fatal("expected no-zygote switch to be appended for child processes")
201+ }
202+}
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 "unsafe"6 "unsafe"
6 7
@@ -24,27 +25,56 @@ type relaunchCommandLineStub struct {
24 commandLineString string25 commandLineString string
25 }26 }
26 27
27-func (c relaunchCommandLineStub) IsValid() bool { return true }28+func (c relaunchCommandLineStub) IsValid() bool { return true }
28-func (c relaunchCommandLineStub) IsReadOnly() bool { return true }29+func (c relaunchCommandLineStub) IsReadOnly() bool { return true }
29-func (c relaunchCommandLineStub) Copy() purecef.CommandLine { return c }30+func (c relaunchCommandLineStub) Copy() purecef.CommandLine { return c }
30-func (c relaunchCommandLineStub) InitFromArgv(int32, unsafe.Pointer) {}31+func (c relaunchCommandLineStub) InitFromArgv(int32, unsafe.Pointer) {}
31-func (c relaunchCommandLineStub) InitFromString(string) {}32+func (c relaunchCommandLineStub) InitFromString(string) {}
32-func (c relaunchCommandLineStub) Reset() {}33+func (c relaunchCommandLineStub) Reset() {}
33-func (c relaunchCommandLineStub) GetArgv(uintptr) {}34+func (c relaunchCommandLineStub) GetArgv(uintptr) {}
34-func (c relaunchCommandLineStub) GetCommandLineString() string { return c.commandLineString }35+func (c relaunchCommandLineStub) GetCommandLineString() string { return c.commandLineString }
35-func (c relaunchCommandLineStub) GetProgram() string { return "dumber" }36+func (c relaunchCommandLineStub) GetProgram() string { return "dumber" }
36-func (c relaunchCommandLineStub) SetProgram(string) {}37+func (c relaunchCommandLineStub) SetProgram(string) {}
37-func (c relaunchCommandLineStub) HasSwitches() bool { return false }38+func (c relaunchCommandLineStub) HasSwitches() bool { return false }
38-func (c relaunchCommandLineStub) HasSwitch(string) bool { return false }39+func (c relaunchCommandLineStub) HasSwitch(string) bool { return false }
39-func (c relaunchCommandLineStub) GetSwitchValue(string) string { return "" }40+func (c relaunchCommandLineStub) GetSwitchValue(string) string { return "" }
40-func (c relaunchCommandLineStub) GetSwitches(uintptr) {}41+func (c relaunchCommandLineStub) GetSwitches(uintptr) {}
41-func (c relaunchCommandLineStub) AppendSwitch(string) {}42+func (c relaunchCommandLineStub) AppendSwitch(string) {}
42-func (c relaunchCommandLineStub) AppendSwitchWithValue(string, string) {}43+func (c relaunchCommandLineStub) AppendSwitchWithValue(string, string) {
43-func (c relaunchCommandLineStub) HasArguments() bool { return true }44+}
44-func (c relaunchCommandLineStub) GetArguments(uintptr) {}45+func (c relaunchCommandLineStub) HasArguments() bool { return true }
45-func (c relaunchCommandLineStub) AppendArgument(string) {}46+func (c relaunchCommandLineStub) GetArguments(uintptr) {}
46-func (c relaunchCommandLineStub) PrependWrapper(string) {}47+func (c relaunchCommandLineStub) AppendArgument(string) {
47-func (c relaunchCommandLineStub) RemoveSwitch(string) {}48+}
49+func (c relaunchCommandLineStub) PrependWrapper(string) {}
50+func (c relaunchCommandLineStub) RemoveSwitch(string) {}
51+
52+type mutableCommandLineStub struct {
53+ relaunchCommandLineStub
54+ switches map[string]string
55+}
56+
57+func newMutableCommandLineStub() *mutableCommandLineStub {
58+ return &mutableCommandLineStub{switches: make(map[string]string)}
59+}
60+
61+func (c *mutableCommandLineStub) IsReadOnly() bool { return false }
62+func (c *mutableCommandLineStub) Copy() purecef.CommandLine { return c }
63+func (c *mutableCommandLineStub) Reset() { c.switches = make(map[string]string) }
64+func (c *mutableCommandLineStub) HasSwitches() bool { return len(c.switches) > 0 }
65+func (c *mutableCommandLineStub) HasSwitch(name string) bool {
66+ _, ok := c.switches[name]
67+ return ok
68+}
69+func (c *mutableCommandLineStub) GetSwitchValue(name string) string { return c.switches[name] }
70+func (c *mutableCommandLineStub) AppendSwitch(name string) { c.switches[name] = "" }
71+func (c *mutableCommandLineStub) AppendSwitchWithValue(name, value string) {
72+ c.switches[name] = value
73+}
74+func (c *mutableCommandLineStub) HasArguments() bool { return false }
75+func (c *mutableCommandLineStub) RemoveSwitch(name string) {
76+ delete(c.switches, name)
77+}
48 78
49 func TestRegisterDumbScheme(t *testing.T) {79 func TestRegisterDumbScheme(t *testing.T) {
50 t.Parallel()80 t.Parallel()
@@ -143,3 +173,30 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardNonBrowse(t *testin
143 t.Fatal("handler should not be called for non-browse relaunch")173 t.Fatal("handler should not be called for non-browse relaunch")
144 }174 }
145 }175 }
176+
177+func TestConfigureCommandLine_AppendsExpectedSwitches(t *testing.T) {
178+ t.Parallel()
179+
180+ commandLine := newMutableCommandLineStub()
181+ configureCommandLine(commandLine)
182+
183+ if !commandLine.HasSwitch("enable-smooth-scrolling") {
184+ t.Fatal("expected enable-smooth-scrolling switch to be appended")
185+ }
186+ if got := commandLine.GetSwitchValue("autoplay-policy"); got != "no-user-gesture-required" {
187+ t.Fatalf("autoplay-policy = %q, want %q", got, "no-user-gesture-required")
188+ }
189+}
190+
191+func TestDumberBPH_OnBeforeChildProcessLaunch_AppendsNoZygote(t *testing.T) {
192+ t.Parallel()
193+
194+ commandLine := newMutableCommandLineStub()
195+ commandLine.AppendSwitchWithValue("type", "renderer")
196+
197+ (&dumberBPH{engine: &Engine{ctx: context.Background()}}).OnBeforeChildProcessLaunch(commandLine)
198+
199+ if !commandLine.HasSwitch("no-zygote") {
200+ t.Fatal("expected no-zygote switch to be appended for child processes")
201+ }
202+}
M internal/infrastructure/cef/content_injector.go +10 -10
@@ -392,15 +392,6 @@ const redditDirectVideoJS = `(function(){
392392 // reasserts browser focus when an editable element gains DOM focus.
393393 const clipboardSelectionFetchBridgeJS = `(function(){
394394 var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395- if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') {
396- window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce);
397- return;
398- }
399- window.__dumberClipboardBridge = {
400- setBridgeNonce: function(nextBridgeNonce) {
401- bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce);
402- }
403- };
404395
405396 function encodeBody(body) {
406397 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
@@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
752743
753744 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
754745 // mode while the native renderer bridge remains disabled.
755- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce()))
746+ bridgeNonce := wv.rotateBridgeNonce()
747+ if bridgeNonce == "" {
748+ ctx := context.Background()
749+ if ci != nil && ci.engine != nil {
750+ ctx = ci.engine.currentContext()
751+ }
752+ logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753+ } else {
754+ wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
755+ }
756756
757757 // Video playback diagnostic — logs video element state changes.
758758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
@@ -392,15 +392,6 @@ const redditDirectVideoJS = `(function(){
392 // reasserts browser focus when an editable element gains DOM focus.392 // reasserts browser focus when an editable element gains DOM focus.
393 const clipboardSelectionFetchBridgeJS = `(function(){393 const clipboardSelectionFetchBridgeJS = `(function(){
394 var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';394 var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395- if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') {
396- window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce);
397- return;
398- }
399- window.__dumberClipboardBridge = {
400- setBridgeNonce: function(nextBridgeNonce) {
401- bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce);
402- }
403- };
404 395
405 function encodeBody(body) {396 function encodeBody(body) {
406 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';397 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
@@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
752 743
753 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR744 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
754 // mode while the native renderer bridge remains disabled.745 // mode while the native renderer bridge remains disabled.
755- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce()))746+ bridgeNonce := wv.rotateBridgeNonce()
747+ if bridgeNonce == "" {
748+ ctx := context.Background()
749+ if ci != nil && ci.engine != nil {
750+ ctx = ci.engine.currentContext()
751+ }
752+ logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
753+ } else {
754+ wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
755+ }
756 756
757 // Video playback diagnostic — logs video element state changes.757 // Video playback diagnostic — logs video element state changes.
758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
M internal/infrastructure/cef/content_injector_test.go +2 -0
@@ -57,5 +57,7 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
5757 require.Contains(t, script, "sendToClipboard(normalized)")
5858 require.Contains(t, script, "mirrorClipboardItems(items)")
5959 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.NotContains(t, script, "__dumberClipboardBridge")
61+ require.NotContains(t, script, "setBridgeNonce")
6062 require.Contains(t, script, "test-bridge-nonce")
6163 }
@@ -57,5 +57,7 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
57 require.Contains(t, script, "sendToClipboard(normalized)")57 require.Contains(t, script, "sendToClipboard(normalized)")
58 require.Contains(t, script, "mirrorClipboardItems(items)")58 require.Contains(t, script, "mirrorClipboardItems(items)")
59 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")59 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.NotContains(t, script, "__dumberClipboardBridge")
61+ require.NotContains(t, script, "setBridgeNonce")
60 require.Contains(t, script, "test-bridge-nonce")62 require.Contains(t, script, "test-bridge-nonce")
61 }63 }
M internal/infrastructure/cef/engine_init.go +93 -44
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -17,7 +18,6 @@ import (
1718 "github.com/bnema/dumber/assets"
1819 "github.com/bnema/dumber/internal/application/port"
1920 "github.com/bnema/dumber/internal/infrastructure/config"
20- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2121 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
2222 "github.com/bnema/dumber/internal/logging"
2323 )
@@ -25,11 +25,17 @@ import (
2525 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2626 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2727
28+// RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
29+type RuntimePaths struct {
30+ StateRoot string
31+ LogFile string
32+}
33+
2834 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2935 func NewEngine(
3036 ctx context.Context,
3137 opts port.EngineOptions,
32- profile runtimeprofile.Profile,
38+ paths RuntimePaths,
3339 cfg RuntimeConfig,
3440 transcodingCfg TranscodingRuntimeConfig,
3541 audioFactory port.AudioOutputFactory,
@@ -37,11 +43,11 @@ func NewEngine(
3743 ) (*Engine, error) {
3844 logger := logging.FromContext(ctx)
3945 deps.MediaClassifier = deps.MediaClassifier.normalize()
40- stateRoot := resolvedStateRoot(profile, opts)
46+ stateRoot := resolvedStateRoot(paths.StateRoot, opts)
4147 cleanStaleSingletonLocks(logger, stateRoot)
4248 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
4349
44- settings, err := prepareCEFSettings(opts, profile, cfg, logger)
50+ settings, err := prepareCEFSettings(opts, paths, cfg, logger)
4551 if err != nil {
4652 return nil, err
4753 }
@@ -91,7 +97,7 @@ func NewEngine(
9197 )
9298 }
9399
94-func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {
100+func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
95101 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
96102 return root
97103 }
@@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions)
101107 if opts.DataDir != "" {
102108 return opts.DataDir
103109 }
104- return profile.CEFUserDataDir()
110+ return defaultStateRoot
105111 }
106112
107113 // prepareCEFSettings builds purecef.Settings from the engine config.
108114 func prepareCEFSettings(
109115 opts port.EngineOptions,
110- profile runtimeprofile.Profile,
116+ paths RuntimePaths,
111117 cfg RuntimeConfig,
112118 logger *zerolog.Logger,
113119 ) (purecef.Settings, error) {
@@ -126,16 +132,16 @@ func prepareCEFSettings(
126132 settings := purecef.DefaultSettings()
127133 settings.MultiThreadedMessageLoop = true
128134 settings.ExternalMessagePump = false
129- settings.RootCachePath = resolvedStateRoot(profile, opts)
135+ settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
130136 if cfg.CEFDir != "" {
131137 settings.CEFDir = cfg.CEFDir
132138 }
133- if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {
139+ if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
134140 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
135141 } else {
136142 settings.LogFile = runtimeLogFile
137143 }
138- if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {
144+ if bootstrapLogFile, err := prepareCEFInitTraceFile(paths.LogFile, cfg.LogFile); err != nil {
139145 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
140146 } else if bootstrapLogFile != "" {
141147 settings.InitTraceFile = bootstrapLogFile
@@ -147,12 +153,6 @@ func prepareCEFSettings(
147153 if cfg.LogSeverity != 0 {
148154 settings.LogSeverity = cfg.LogSeverity
149155 }
150- if helperPath := findHelperBinary(); helperPath != "" {
151- settings.BrowserSubprocessPath = helperPath
152- logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
153- } else {
154- logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
155- }
156156 return settings, nil
157157 }
158158
@@ -407,60 +407,109 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
407407 }
408408 }
409409
410-// findHelperBinary looks for the cef-helper binary next to the running
411-// executable. Returns empty string if not found.
412-func findHelperBinary() string {
413- exe, err := os.Executable()
414- if err != nil {
415- return ""
416- }
417- dir := filepath.Dir(exe)
418- helper := filepath.Join(dir, "cef-helper")
419- if _, err := os.Stat(helper); err == nil {
420- return helper
421- }
422- return ""
423-}
424-
425410 const (
426411 dirPerm = 0o700
427412 filePerm = 0o600
428413 )
429414
430-func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) {
431- runtimeLogFile := logFile
432- if runtimeLogFile != "" {
433- runtimeLogFile = filepath.Clean(runtimeLogFile)
434- } else {
435- runtimeLogFile = profile.CEFLogFile()
415+func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) {
416+ runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
417+ if runtimeLogFile == "" {
418+ return "", nil
436419 }
437420 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
438421 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
439422 }
423+
424+ file, err := openRegularLogFile(runtimeLogFile)
425+ if err != nil {
426+ return "", err
427+ }
428+ defer func() { _ = file.Close() }()
429+
430+ info, err := file.Stat()
431+ if err != nil {
432+ return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err)
433+ }
434+ if !info.Mode().IsRegular() {
435+ return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile)
436+ }
437+ if err := file.Chmod(filePerm); err != nil {
438+ return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err)
439+ }
440440 return runtimeLogFile, nil
441441 }
442442
443-func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {
443+func openRegularLogFile(path string) (*os.File, error) {
444+ info, err := os.Lstat(path)
445+ if err != nil {
446+ if !errors.Is(err, os.ErrNotExist) {
447+ return nil, fmt.Errorf("lstat %s: %w", path, err)
448+ }
449+ file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm)
450+ if createErr != nil {
451+ return nil, fmt.Errorf("create %s: %w", path, createErr)
452+ }
453+ return file, nil
454+ }
455+ if info.Mode()&os.ModeSymlink != 0 {
456+ return nil, fmt.Errorf("log path %s must not be a symlink", path)
457+ }
458+ if !info.Mode().IsRegular() {
459+ return nil, fmt.Errorf("log path %s is not a regular file", path)
460+ }
461+ file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0)
462+ if openErr != nil {
463+ return nil, fmt.Errorf("open %s: %w", path, openErr)
464+ }
465+ return file, nil
466+}
467+
468+func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) {
469+ fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm))
470+ if err != nil {
471+ return nil, err
472+ }
473+ return os.NewFile(uintptr(fd), path), nil
474+}
475+
476+func prepareCEFInitTraceFile(defaultLogFile, logFile string) (string, error) {
444477 if !puregoCEFInitTraceEnabled() {
445478 return "", nil
446479 }
447480
448- runtimeLogFile := logFile
449- if runtimeLogFile != "" {
450- runtimeLogFile = filepath.Clean(runtimeLogFile)
451- } else {
452- runtimeLogFile = profile.CEFLogFile()
481+ runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
482+ if runtimeLogFile == "" {
483+ return "", nil
453484 }
454485 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
455486 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
456487 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)
457488 }
458- if err := os.WriteFile(bootstrapLogFile, nil, filePerm); err != nil {
489+ file, err := openRegularLogFile(bootstrapLogFile)
490+ if err != nil {
491+ return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
492+ }
493+ defer func() { _ = file.Close() }()
494+ if err := file.Truncate(0); err != nil {
459495 return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
460496 }
497+ if err := file.Chmod(filePerm); err != nil {
498+ return "", fmt.Errorf("chmod %s: %w", bootstrapLogFile, err)
499+ }
461500 return bootstrapLogFile, nil
462501 }
463502
503+func resolveLogFile(defaultLogFile, logFile string) string {
504+ if strings.TrimSpace(logFile) != "" {
505+ return filepath.Clean(logFile)
506+ }
507+ if strings.TrimSpace(defaultLogFile) != "" {
508+ return filepath.Clean(defaultLogFile)
509+ }
510+ return ""
511+}
512+
464513 func puregoCEFInitTraceEnabled() bool {
465514 return envBoolEnabled(puregoCEFInitTraceEnvVar)
466515 }
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -17,7 +18,6 @@ import (
17 "github.com/bnema/dumber/assets"18 "github.com/bnema/dumber/assets"
18 "github.com/bnema/dumber/internal/application/port"19 "github.com/bnema/dumber/internal/application/port"
19 "github.com/bnema/dumber/internal/infrastructure/config"20 "github.com/bnema/dumber/internal/infrastructure/config"
20- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
21 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"21 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
22 "github.com/bnema/dumber/internal/logging"22 "github.com/bnema/dumber/internal/logging"
23 )23 )
@@ -25,11 +25,17 @@ import (
25 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"25 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
26 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"26 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
27 27
28+// RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
29+type RuntimePaths struct {
30+ StateRoot string
31+ LogFile string
32+}
33+
28 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.34 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
29 func NewEngine(35 func NewEngine(
30 ctx context.Context,36 ctx context.Context,
31 opts port.EngineOptions,37 opts port.EngineOptions,
32- profile runtimeprofile.Profile,38+ paths RuntimePaths,
33 cfg RuntimeConfig,39 cfg RuntimeConfig,
34 transcodingCfg TranscodingRuntimeConfig,40 transcodingCfg TranscodingRuntimeConfig,
35 audioFactory port.AudioOutputFactory,41 audioFactory port.AudioOutputFactory,
@@ -37,11 +43,11 @@ func NewEngine(
37 ) (*Engine, error) {43 ) (*Engine, error) {
38 logger := logging.FromContext(ctx)44 logger := logging.FromContext(ctx)
39 deps.MediaClassifier = deps.MediaClassifier.normalize()45 deps.MediaClassifier = deps.MediaClassifier.normalize()
40- stateRoot := resolvedStateRoot(profile, opts)46+ stateRoot := resolvedStateRoot(paths.StateRoot, opts)
41 cleanStaleSingletonLocks(logger, stateRoot)47 cleanStaleSingletonLocks(logger, stateRoot)
42 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()48 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
43 49
44- settings, err := prepareCEFSettings(opts, profile, cfg, logger)50+ settings, err := prepareCEFSettings(opts, paths, cfg, logger)
45 if err != nil {51 if err != nil {
46 return nil, err52 return nil, err
47 }53 }
@@ -91,7 +97,7 @@ func NewEngine(
91 )97 )
92 }98 }
93 99
94-func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {100+func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
95 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {101 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
96 return root102 return root
97 }103 }
@@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions)
101 if opts.DataDir != "" {107 if opts.DataDir != "" {
102 return opts.DataDir108 return opts.DataDir
103 }109 }
104- return profile.CEFUserDataDir()110+ return defaultStateRoot
105 }111 }
106 112
107 // prepareCEFSettings builds purecef.Settings from the engine config.113 // prepareCEFSettings builds purecef.Settings from the engine config.
108 func prepareCEFSettings(114 func prepareCEFSettings(
109 opts port.EngineOptions,115 opts port.EngineOptions,
110- profile runtimeprofile.Profile,116+ paths RuntimePaths,
111 cfg RuntimeConfig,117 cfg RuntimeConfig,
112 logger *zerolog.Logger,118 logger *zerolog.Logger,
113 ) (purecef.Settings, error) {119 ) (purecef.Settings, error) {
@@ -126,16 +132,16 @@ func prepareCEFSettings(
126 settings := purecef.DefaultSettings()132 settings := purecef.DefaultSettings()
127 settings.MultiThreadedMessageLoop = true133 settings.MultiThreadedMessageLoop = true
128 settings.ExternalMessagePump = false134 settings.ExternalMessagePump = false
129- settings.RootCachePath = resolvedStateRoot(profile, opts)135+ settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
130 if cfg.CEFDir != "" {136 if cfg.CEFDir != "" {
131 settings.CEFDir = cfg.CEFDir137 settings.CEFDir = cfg.CEFDir
132 }138 }
133- if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {139+ if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
134 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")140 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
135 } else {141 } else {
136 settings.LogFile = runtimeLogFile142 settings.LogFile = runtimeLogFile
137 }143 }
138- if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {144+ if bootstrapLogFile, err := prepareCEFInitTraceFile(paths.LogFile, cfg.LogFile); err != nil {
139 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")145 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
140 } else if bootstrapLogFile != "" {146 } else if bootstrapLogFile != "" {
141 settings.InitTraceFile = bootstrapLogFile147 settings.InitTraceFile = bootstrapLogFile
@@ -147,12 +153,6 @@ func prepareCEFSettings(
147 if cfg.LogSeverity != 0 {153 if cfg.LogSeverity != 0 {
148 settings.LogSeverity = cfg.LogSeverity154 settings.LogSeverity = cfg.LogSeverity
149 }155 }
150- if helperPath := findHelperBinary(); helperPath != "" {
151- settings.BrowserSubprocessPath = helperPath
152- logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
153- } else {
154- logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
155- }
156 return settings, nil156 return settings, nil
157 }157 }
158 158
@@ -407,60 +407,109 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
407 }407 }
408 }408 }
409 409
410-// findHelperBinary looks for the cef-helper binary next to the running
411-// executable. Returns empty string if not found.
412-func findHelperBinary() string {
413- exe, err := os.Executable()
414- if err != nil {
415- return ""
416- }
417- dir := filepath.Dir(exe)
418- helper := filepath.Join(dir, "cef-helper")
419- if _, err := os.Stat(helper); err == nil {
420- return helper
421- }
422- return ""
423-}
424-
425 const (410 const (
426 dirPerm = 0o700411 dirPerm = 0o700
427 filePerm = 0o600412 filePerm = 0o600
428 )413 )
429 414
430-func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) {415+func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) {
431- runtimeLogFile := logFile416+ runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
432- if runtimeLogFile != "" {417+ if runtimeLogFile == "" {
433- runtimeLogFile = filepath.Clean(runtimeLogFile)418+ return "", nil
434- } else {
435- runtimeLogFile = profile.CEFLogFile()
436 }419 }
437 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {420 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
438 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)421 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
439 }422 }
423+
424+ file, err := openRegularLogFile(runtimeLogFile)
425+ if err != nil {
426+ return "", err
427+ }
428+ defer func() { _ = file.Close() }()
429+
430+ info, err := file.Stat()
431+ if err != nil {
432+ return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err)
433+ }
434+ if !info.Mode().IsRegular() {
435+ return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile)
436+ }
437+ if err := file.Chmod(filePerm); err != nil {
438+ return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err)
439+ }
440 return runtimeLogFile, nil440 return runtimeLogFile, nil
441 }441 }
442 442
443-func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {443+func openRegularLogFile(path string) (*os.File, error) {
444+ info, err := os.Lstat(path)
445+ if err != nil {
446+ if !errors.Is(err, os.ErrNotExist) {
447+ return nil, fmt.Errorf("lstat %s: %w", path, err)
448+ }
449+ file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm)
450+ if createErr != nil {
451+ return nil, fmt.Errorf("create %s: %w", path, createErr)
452+ }
453+ return file, nil
454+ }
455+ if info.Mode()&os.ModeSymlink != 0 {
456+ return nil, fmt.Errorf("log path %s must not be a symlink", path)
457+ }
458+ if !info.Mode().IsRegular() {
459+ return nil, fmt.Errorf("log path %s is not a regular file", path)
460+ }
461+ file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0)
462+ if openErr != nil {
463+ return nil, fmt.Errorf("open %s: %w", path, openErr)
464+ }
465+ return file, nil
466+}
467+
468+func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) {
469+ fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm))
470+ if err != nil {
471+ return nil, err
472+ }
473+ return os.NewFile(uintptr(fd), path), nil
474+}
475+
476+func prepareCEFInitTraceFile(defaultLogFile, logFile string) (string, error) {
444 if !puregoCEFInitTraceEnabled() {477 if !puregoCEFInitTraceEnabled() {
445 return "", nil478 return "", nil
446 }479 }
447 480
448- runtimeLogFile := logFile481+ runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
449- if runtimeLogFile != "" {482+ if runtimeLogFile == "" {
450- runtimeLogFile = filepath.Clean(runtimeLogFile)483+ return "", nil
451- } else {
452- runtimeLogFile = profile.CEFLogFile()
453 }484 }
454 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"485 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
455 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {486 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
456 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)487 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err)
457 }488 }
458- if err := os.WriteFile(bootstrapLogFile, nil, filePerm); err != nil {489+ file, err := openRegularLogFile(bootstrapLogFile)
490+ if err != nil {
491+ return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
492+ }
493+ defer func() { _ = file.Close() }()
494+ if err := file.Truncate(0); err != nil {
459 return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)495 return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err)
460 }496 }
497+ if err := file.Chmod(filePerm); err != nil {
498+ return "", fmt.Errorf("chmod %s: %w", bootstrapLogFile, err)
499+ }
461 return bootstrapLogFile, nil500 return bootstrapLogFile, nil
462 }501 }
463 502
503+func resolveLogFile(defaultLogFile, logFile string) string {
504+ if strings.TrimSpace(logFile) != "" {
505+ return filepath.Clean(logFile)
506+ }
507+ if strings.TrimSpace(defaultLogFile) != "" {
508+ return filepath.Clean(defaultLogFile)
509+ }
510+ return ""
511+}
512+
464 func puregoCEFInitTraceEnabled() bool {513 func puregoCEFInitTraceEnabled() bool {
465 return envBoolEnabled(puregoCEFInitTraceEnvVar)514 return envBoolEnabled(puregoCEFInitTraceEnvVar)
466 }515 }
M internal/infrastructure/cef/engine_init_test.go +101 -14
@@ -14,7 +14,7 @@ import (
1414
1515 func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
1616 profile := testCEFDevProfile(t)
17- root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
17+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
1818 require.Equal(t, "/tmp/cache", root)
1919 }
2020
@@ -22,65 +22,113 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
2222 profile := testCEFDevProfile(t)
2323 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
2424
25- root := resolvedStateRoot(profile, port.EngineOptions{})
25+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
2626 require.Equal(t, "/tmp/override", root)
2727
28- root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
28+ root = resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
2929 require.Equal(t, "/tmp/override", root)
3030 }
3131
3232 func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
3333 profile := testCEFProdProfile(t)
34- root := resolvedStateRoot(profile, port.EngineOptions{})
34+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
3535 require.Equal(t, profile.CEFUserDataDir(), root)
3636 }
3737
3838 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
3939 logger := zerolog.Nop()
4040 profile := testCEFDevProfile(t)
41- settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger)
41+ settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
4242 require.NoError(t, err)
4343 require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
4444 require.Equal(t, profile.CEFLogFile(), settings.LogFile)
45+ require.Empty(t, settings.BrowserSubprocessPath)
4546 }
4647
4748 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
4849 logger := zerolog.Nop()
49- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
50+ profile := testCEFDevProfile(t)
51+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
5052 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
5153 }
5254
5355 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
5456 logger := zerolog.Nop()
55- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
57+ profile := testCEFDevProfile(t)
58+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
5659 require.NoError(t, err)
5760 }
5861
5962 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
6063 t.Setenv(puregoCEFInitTraceEnvVar, "")
64+ profile := testCEFDevProfile(t)
6165
62- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")
66+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
6367 require.NoError(t, err)
6468 require.Empty(t, path)
6569 }
6670
67-func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) {
71+func TestPrepareCEFLogFile_CreatesPrivateDirectoryAndFile(t *testing.T) {
72+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
73+
74+ path, err := prepareCEFLogFile("", logFile)
75+ require.NoError(t, err)
76+ require.Equal(t, logFile, path)
77+
78+ dirInfo, err := os.Stat(filepath.Dir(logFile))
79+ require.NoError(t, err)
80+ require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())
81+
82+ fileInfo, err := os.Stat(logFile)
83+ require.NoError(t, err)
84+ require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm())
85+}
86+
87+func TestPrepareCEFLogFile_TightensExistingFilePermissions(t *testing.T) {
6888 logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
89+ require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm))
90+ require.NoError(t, os.WriteFile(logFile, []byte("existing"), 0o644))
6991
70- path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile)
92+ path, err := prepareCEFLogFile("", logFile)
7193 require.NoError(t, err)
7294 require.Equal(t, logFile, path)
7395
74- info, err := os.Stat(filepath.Dir(logFile))
96+ fileInfo, err := os.Stat(logFile)
7597 require.NoError(t, err)
76- require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm())
98+ require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm())
99+}
100+
101+func TestPrepareCEFLogFile_RejectsSymlink(t *testing.T) {
102+ target := filepath.Join(t.TempDir(), "target.log")
103+ require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644))
104+
105+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
106+ require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm))
107+ require.NoError(t, os.Symlink(target, logFile))
108+
109+ path, err := prepareCEFLogFile("", logFile)
110+ require.ErrorContains(t, err, "must not be a symlink")
111+ require.Empty(t, path)
112+
113+ targetInfo, statErr := os.Stat(target)
114+ require.NoError(t, statErr)
115+ require.Equal(t, os.FileMode(0o644), targetInfo.Mode().Perm())
116+}
117+
118+func TestPrepareCEFLogFile_RejectsDirectoryPath(t *testing.T) {
119+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
120+ require.NoError(t, os.MkdirAll(logFile, dirPerm))
121+
122+ path, err := prepareCEFLogFile("", logFile)
123+ require.ErrorContains(t, err, "not a regular file")
124+ require.Empty(t, path)
77125 }
78126
79127 func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
80128 t.Setenv(puregoCEFInitTraceEnvVar, "1")
81129 logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
82130
83- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)
131+ path, err := prepareCEFInitTraceFile("", logFile)
84132 require.NoError(t, err)
85133 require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
86134 require.FileExists(t, path)
@@ -94,11 +142,50 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
94142 require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())
95143 }
96144
145+func TestPrepareCEFInitTraceFile_TruncatesExistingFile(t *testing.T) {
146+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
147+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
148+ bootstrapLogFile := filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log")
149+ require.NoError(t, os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm))
150+ require.NoError(t, os.WriteFile(bootstrapLogFile, []byte("existing"), 0o644))
151+
152+ path, err := prepareCEFInitTraceFile("", logFile)
153+ require.NoError(t, err)
154+ require.Equal(t, bootstrapLogFile, path)
155+
156+ content, readErr := os.ReadFile(path)
157+ require.NoError(t, readErr)
158+ require.Empty(t, content)
159+
160+ info, statErr := os.Stat(path)
161+ require.NoError(t, statErr)
162+ require.Equal(t, os.FileMode(filePerm), info.Mode().Perm())
163+}
164+
165+func TestPrepareCEFInitTraceFile_RejectsSymlink(t *testing.T) {
166+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
167+ target := filepath.Join(t.TempDir(), "target.bootstrap.log")
168+ require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644))
169+
170+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
171+ bootstrapLogFile := filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log")
172+ require.NoError(t, os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm))
173+ require.NoError(t, os.Symlink(target, bootstrapLogFile))
174+
175+ path, err := prepareCEFInitTraceFile("", logFile)
176+ require.ErrorContains(t, err, "must not be a symlink")
177+ require.Empty(t, path)
178+
179+ targetInfo, statErr := os.Stat(target)
180+ require.NoError(t, statErr)
181+ require.Equal(t, os.FileMode(0o644), targetInfo.Mode().Perm())
182+}
183+
97184 func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
98185 t.Setenv(puregoCEFInitTraceEnvVar, "1")
99186 profile := testCEFDevProfile(t)
100187
101- path, err := prepareCEFInitTraceFile(profile, "")
188+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
102189 require.NoError(t, err)
103190 require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
104191 require.FileExists(t, path)
@@ -14,7 +14,7 @@ import (
14 14
15 func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {15 func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
16 profile := testCEFDevProfile(t)16 profile := testCEFDevProfile(t)
17- root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})17+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
18 require.Equal(t, "/tmp/cache", root)18 require.Equal(t, "/tmp/cache", root)
19 }19 }
20 20
@@ -22,65 +22,113 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
22 profile := testCEFDevProfile(t)22 profile := testCEFDevProfile(t)
23 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")23 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
24 24
25- root := resolvedStateRoot(profile, port.EngineOptions{})25+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
26 require.Equal(t, "/tmp/override", root)26 require.Equal(t, "/tmp/override", root)
27 27
28- root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})28+ root = resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
29 require.Equal(t, "/tmp/override", root)29 require.Equal(t, "/tmp/override", root)
30 }30 }
31 31
32 func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {32 func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
33 profile := testCEFProdProfile(t)33 profile := testCEFProdProfile(t)
34- root := resolvedStateRoot(profile, port.EngineOptions{})34+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
35 require.Equal(t, profile.CEFUserDataDir(), root)35 require.Equal(t, profile.CEFUserDataDir(), root)
36 }36 }
37 37
38 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {38 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
39 logger := zerolog.Nop()39 logger := zerolog.Nop()
40 profile := testCEFDevProfile(t)40 profile := testCEFDevProfile(t)
41- settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger)41+ settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
42 require.NoError(t, err)42 require.NoError(t, err)
43 require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)43 require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
44 require.Equal(t, profile.CEFLogFile(), settings.LogFile)44 require.Equal(t, profile.CEFLogFile(), settings.LogFile)
45+ require.Empty(t, settings.BrowserSubprocessPath)
45 }46 }
46 47
47 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {48 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
48 logger := zerolog.Nop()49 logger := zerolog.Nop()
49- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger)50+ profile := testCEFDevProfile(t)
51+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
50 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)52 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
51 }53 }
52 54
53 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {55 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
54 logger := zerolog.Nop()56 logger := zerolog.Nop()
55- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger)57+ profile := testCEFDevProfile(t)
58+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
56 require.NoError(t, err)59 require.NoError(t, err)
57 }60 }
58 61
59 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {62 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
60 t.Setenv(puregoCEFInitTraceEnvVar, "")63 t.Setenv(puregoCEFInitTraceEnvVar, "")
64+ profile := testCEFDevProfile(t)
61 65
62- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")66+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
63 require.NoError(t, err)67 require.NoError(t, err)
64 require.Empty(t, path)68 require.Empty(t, path)
65 }69 }
66 70
67-func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) {71+func TestPrepareCEFLogFile_CreatesPrivateDirectoryAndFile(t *testing.T) {
72+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
73+
74+ path, err := prepareCEFLogFile("", logFile)
75+ require.NoError(t, err)
76+ require.Equal(t, logFile, path)
77+
78+ dirInfo, err := os.Stat(filepath.Dir(logFile))
79+ require.NoError(t, err)
80+ require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())
81+
82+ fileInfo, err := os.Stat(logFile)
83+ require.NoError(t, err)
84+ require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm())
85+}
86+
87+func TestPrepareCEFLogFile_TightensExistingFilePermissions(t *testing.T) {
68 logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")88 logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
89+ require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm))
90+ require.NoError(t, os.WriteFile(logFile, []byte("existing"), 0o644))
69 91
70- path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile)92+ path, err := prepareCEFLogFile("", logFile)
71 require.NoError(t, err)93 require.NoError(t, err)
72 require.Equal(t, logFile, path)94 require.Equal(t, logFile, path)
73 95
74- info, err := os.Stat(filepath.Dir(logFile))96+ fileInfo, err := os.Stat(logFile)
75 require.NoError(t, err)97 require.NoError(t, err)
76- require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm())98+ require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm())
99+}
100+
101+func TestPrepareCEFLogFile_RejectsSymlink(t *testing.T) {
102+ target := filepath.Join(t.TempDir(), "target.log")
103+ require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644))
104+
105+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
106+ require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm))
107+ require.NoError(t, os.Symlink(target, logFile))
108+
109+ path, err := prepareCEFLogFile("", logFile)
110+ require.ErrorContains(t, err, "must not be a symlink")
111+ require.Empty(t, path)
112+
113+ targetInfo, statErr := os.Stat(target)
114+ require.NoError(t, statErr)
115+ require.Equal(t, os.FileMode(0o644), targetInfo.Mode().Perm())
116+}
117+
118+func TestPrepareCEFLogFile_RejectsDirectoryPath(t *testing.T) {
119+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
120+ require.NoError(t, os.MkdirAll(logFile, dirPerm))
121+
122+ path, err := prepareCEFLogFile("", logFile)
123+ require.ErrorContains(t, err, "not a regular file")
124+ require.Empty(t, path)
77 }125 }
78 126
79 func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {127 func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
80 t.Setenv(puregoCEFInitTraceEnvVar, "1")128 t.Setenv(puregoCEFInitTraceEnvVar, "1")
81 logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")129 logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
82 130
83- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)131+ path, err := prepareCEFInitTraceFile("", logFile)
84 require.NoError(t, err)132 require.NoError(t, err)
85 require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)133 require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
86 require.FileExists(t, path)134 require.FileExists(t, path)
@@ -94,11 +142,50 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
94 require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())142 require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())
95 }143 }
96 144
145+func TestPrepareCEFInitTraceFile_TruncatesExistingFile(t *testing.T) {
146+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
147+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
148+ bootstrapLogFile := filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log")
149+ require.NoError(t, os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm))
150+ require.NoError(t, os.WriteFile(bootstrapLogFile, []byte("existing"), 0o644))
151+
152+ path, err := prepareCEFInitTraceFile("", logFile)
153+ require.NoError(t, err)
154+ require.Equal(t, bootstrapLogFile, path)
155+
156+ content, readErr := os.ReadFile(path)
157+ require.NoError(t, readErr)
158+ require.Empty(t, content)
159+
160+ info, statErr := os.Stat(path)
161+ require.NoError(t, statErr)
162+ require.Equal(t, os.FileMode(filePerm), info.Mode().Perm())
163+}
164+
165+func TestPrepareCEFInitTraceFile_RejectsSymlink(t *testing.T) {
166+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
167+ target := filepath.Join(t.TempDir(), "target.bootstrap.log")
168+ require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644))
169+
170+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
171+ bootstrapLogFile := filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log")
172+ require.NoError(t, os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm))
173+ require.NoError(t, os.Symlink(target, bootstrapLogFile))
174+
175+ path, err := prepareCEFInitTraceFile("", logFile)
176+ require.ErrorContains(t, err, "must not be a symlink")
177+ require.Empty(t, path)
178+
179+ targetInfo, statErr := os.Stat(target)
180+ require.NoError(t, statErr)
181+ require.Equal(t, os.FileMode(0o644), targetInfo.Mode().Perm())
182+}
183+
97 func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {184 func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
98 t.Setenv(puregoCEFInitTraceEnvVar, "1")185 t.Setenv(puregoCEFInitTraceEnvVar, "1")
99 profile := testCEFDevProfile(t)186 profile := testCEFDevProfile(t)
100 187
101- path, err := prepareCEFInitTraceFile(profile, "")188+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
102 require.NoError(t, err)189 require.NoError(t, err)
103 require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)190 require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
104 require.FileExists(t, path)191 require.FileExists(t, path)
M internal/infrastructure/cef/webview.go +7 -3
@@ -8,7 +8,6 @@ import (
88 "errors"
99 "fmt"
1010 "math"
11- "strconv"
1211 "strings"
1312 "sync"
1413 "sync/atomic"
@@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
489488 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
490489 const colorScale = 255
491490
491+var bridgeNonceRandom = rand.Read
492+
492493 func newBridgeNonce() string {
493494 buf := make([]byte, 16)
494- if _, err := rand.Read(buf); err != nil {
495- return strconv.FormatInt(time.Now().UnixNano(), 16)
495+ if _, err := bridgeNonceRandom(buf); err != nil {
496+ return ""
496497 }
497498 return hex.EncodeToString(buf)
498499 }
499500
500501 func (wv *WebView) rotateBridgeNonce() string {
501502 nonce := newBridgeNonce()
503+ if nonce == "" {
504+ return ""
505+ }
502506 wv.mu.Lock()
503507 wv.bridgeNonce = nonce
504508 wv.mu.Unlock()
@@ -8,7 +8,6 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "math"10 "math"
11- "strconv"
12 "strings"11 "strings"
13 "sync"12 "sync"
14 "sync/atomic"13 "sync/atomic"
@@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
489 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.488 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
490 const colorScale = 255489 const colorScale = 255
491 490
491+var bridgeNonceRandom = rand.Read
492+
492 func newBridgeNonce() string {493 func newBridgeNonce() string {
493 buf := make([]byte, 16)494 buf := make([]byte, 16)
494- if _, err := rand.Read(buf); err != nil {495+ if _, err := bridgeNonceRandom(buf); err != nil {
495- return strconv.FormatInt(time.Now().UnixNano(), 16)496+ return ""
496 }497 }
497 return hex.EncodeToString(buf)498 return hex.EncodeToString(buf)
498 }499 }
499 500
500 func (wv *WebView) rotateBridgeNonce() string {501 func (wv *WebView) rotateBridgeNonce() string {
501 nonce := newBridgeNonce()502 nonce := newBridgeNonce()
503+ if nonce == "" {
504+ return ""
505+ }
502 wv.mu.Lock()506 wv.mu.Lock()
503 wv.bridgeNonce = nonce507 wv.bridgeNonce = nonce
504 wv.mu.Unlock()508 wv.mu.Unlock()
A internal/infrastructure/cef/webview_bridge_nonce_test.go +57 -0
@@ -0,0 +1,57 @@
1+package cef
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNewBridgeNonce_ReturnsEmptyOnRandomFailure(t *testing.T) {
11+ oldBridgeNonceRandom := bridgeNonceRandom
12+ bridgeNonceRandom = func([]byte) (int, error) {
13+ return 0, errors.New("entropy unavailable")
14+ }
15+ defer func() {
16+ bridgeNonceRandom = oldBridgeNonceRandom
17+ }()
18+
19+ require.Empty(t, newBridgeNonce())
20+}
21+
22+func TestWebViewRotateBridgeNonce_PreservesExistingNonceOnRandomFailure(t *testing.T) {
23+ oldBridgeNonceRandom := bridgeNonceRandom
24+ bridgeNonceRandom = func([]byte) (int, error) {
25+ return 0, errors.New("entropy unavailable")
26+ }
27+ defer func() {
28+ bridgeNonceRandom = oldBridgeNonceRandom
29+ }()
30+
31+ wv := &WebView{bridgeNonce: "existing-nonce"}
32+ require.Empty(t, wv.rotateBridgeNonce())
33+
34+ wv.mu.RLock()
35+ defer wv.mu.RUnlock()
36+ require.Equal(t, "existing-nonce", wv.bridgeNonce)
37+}
38+
39+func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) {
40+ oldBridgeNonceRandom := bridgeNonceRandom
41+ bridgeNonceRandom = func(buf []byte) (int, error) {
42+ for i := range buf {
43+ buf[i] = byte(i + 1)
44+ }
45+ return len(buf), nil
46+ }
47+ defer func() {
48+ bridgeNonceRandom = oldBridgeNonceRandom
49+ }()
50+
51+ wv := &WebView{bridgeNonce: "old-nonce"}
52+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.rotateBridgeNonce())
53+
54+ wv.mu.RLock()
55+ defer wv.mu.RUnlock()
56+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)
57+}
@@ -0,0 +1,57 @@
1+package cef
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNewBridgeNonce_ReturnsEmptyOnRandomFailure(t *testing.T) {
11+ oldBridgeNonceRandom := bridgeNonceRandom
12+ bridgeNonceRandom = func([]byte) (int, error) {
13+ return 0, errors.New("entropy unavailable")
14+ }
15+ defer func() {
16+ bridgeNonceRandom = oldBridgeNonceRandom
17+ }()
18+
19+ require.Empty(t, newBridgeNonce())
20+}
21+
22+func TestWebViewRotateBridgeNonce_PreservesExistingNonceOnRandomFailure(t *testing.T) {
23+ oldBridgeNonceRandom := bridgeNonceRandom
24+ bridgeNonceRandom = func([]byte) (int, error) {
25+ return 0, errors.New("entropy unavailable")
26+ }
27+ defer func() {
28+ bridgeNonceRandom = oldBridgeNonceRandom
29+ }()
30+
31+ wv := &WebView{bridgeNonce: "existing-nonce"}
32+ require.Empty(t, wv.rotateBridgeNonce())
33+
34+ wv.mu.RLock()
35+ defer wv.mu.RUnlock()
36+ require.Equal(t, "existing-nonce", wv.bridgeNonce)
37+}
38+
39+func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) {
40+ oldBridgeNonceRandom := bridgeNonceRandom
41+ bridgeNonceRandom = func(buf []byte) (int, error) {
42+ for i := range buf {
43+ buf[i] = byte(i + 1)
44+ }
45+ return len(buf), nil
46+ }
47+ defer func() {
48+ bridgeNonceRandom = oldBridgeNonceRandom
49+ }()
50+
51+ wv := &WebView{bridgeNonce: "old-nonce"}
52+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.rotateBridgeNonce())
53+
54+ wv.mu.RLock()
55+ defer wv.mu.RUnlock()
56+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce)
57+}
M internal/infrastructure/webkit/injector.go +18 -1
@@ -100,6 +100,20 @@ const explicitCopyScript = `(function() {
100100 return selection ? selection.toString() : '';
101101 }
102102
103+ function hasUserActivation() {
104+ try {
105+ if (navigator.userActivation && navigator.userActivation.isActive) {
106+ return true;
107+ }
108+ } catch (_) {}
109+ try {
110+ if (document.hasTransientActivation) {
111+ return true;
112+ }
113+ } catch (_) {}
114+ return false;
115+ }
116+
103117 function postExplicitCopy(action, text) {
104118 if (!text) {
105119 return;
@@ -153,9 +167,12 @@ const explicitCopyScript = `(function() {
153167 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
154168 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
155169 navigator.clipboard.writeText = function(text) {
170+ var userActivated = hasUserActivation();
156171 return Promise.resolve(originalWriteText(text)).then(function(result) {
157172 // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.
158- postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
173+ if (userActivated) {
174+ postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
175+ }
159176 return result;
160177 });
161178 };
@@ -100,6 +100,20 @@ const explicitCopyScript = `(function() {
100 return selection ? selection.toString() : '';100 return selection ? selection.toString() : '';
101 }101 }
102 102
103+ function hasUserActivation() {
104+ try {
105+ if (navigator.userActivation && navigator.userActivation.isActive) {
106+ return true;
107+ }
108+ } catch (_) {}
109+ try {
110+ if (document.hasTransientActivation) {
111+ return true;
112+ }
113+ } catch (_) {}
114+ return false;
115+ }
116+
103 function postExplicitCopy(action, text) {117 function postExplicitCopy(action, text) {
104 if (!text) {118 if (!text) {
105 return;119 return;
@@ -153,9 +167,12 @@ const explicitCopyScript = `(function() {
153 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {167 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
154 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);168 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
155 navigator.clipboard.writeText = function(text) {169 navigator.clipboard.writeText = function(text) {
170+ var userActivated = hasUserActivation();
156 return Promise.resolve(originalWriteText(text)).then(function(result) {171 return Promise.resolve(originalWriteText(text)).then(function(result) {
157 // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.172 // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.
158- postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));173+ if (userActivated) {
174+ postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
175+ }
159 return result;176 return result;
160 });177 });
161 };178 };
M internal/infrastructure/webkit/injector_accent_test.go +3 -0
@@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
3939 assert.Contains(t, script, "document.addEventListener('cut'")
4040 assert.Contains(t, script, "document.execCommand")
4141 assert.Contains(t, script, "navigator.clipboard.writeText")
42+ assert.Contains(t, script, "navigator.userActivation")
43+ assert.Contains(t, script, "var userActivated = hasUserActivation()")
44+ assert.Contains(t, script, "if (userActivated)")
4245 assert.Contains(t, script, "try {")
4346 assert.Contains(t, script, "finally {")
4447 assert.Contains(t, script, "pendingCommand === command")
@@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
39 assert.Contains(t, script, "document.addEventListener('cut'")39 assert.Contains(t, script, "document.addEventListener('cut'")
40 assert.Contains(t, script, "document.execCommand")40 assert.Contains(t, script, "document.execCommand")
41 assert.Contains(t, script, "navigator.clipboard.writeText")41 assert.Contains(t, script, "navigator.clipboard.writeText")
42+ assert.Contains(t, script, "navigator.userActivation")
43+ assert.Contains(t, script, "var userActivated = hasUserActivation()")
44+ assert.Contains(t, script, "if (userActivated)")
42 assert.Contains(t, script, "try {")45 assert.Contains(t, script, "try {")
43 assert.Contains(t, script, "finally {")46 assert.Contains(t, script, "finally {")
44 assert.Contains(t, script, "pendingCommand === command")47 assert.Contains(t, script, "pendingCommand === command")

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close