Merge pull request #225 from bnema/fix/cef-clipboard-input-focus
fix: harden CEF startup and remove helper binary
18 files · +475 -147
M .gitignore +0 -1
| @@ -8,7 +8,6 @@ dist/ | |||
| 8 | 8 | ||
| 9 | 9 | # Binaries for programs and plugins | |
| 10 | 10 | /dumber | |
| 11 | - | /cef-helper | |
| 12 | 11 | dumber.exe | |
| 13 | 12 | ||
| 14 | 13 | # Test binary, built with `go test -c` | |
| @@ -8,7 +8,6 @@ dist/ | |||||
| 8 | 8 | ||||
| 9 | # Binaries for programs and plugins | 9 | # Binaries for programs and plugins | ||
| 10 | /dumber | 10 | /dumber | ||
| 11 | - | /cef-helper | |||
| 12 | dumber.exe | 11 | 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 @@ | |||
| 1 | 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 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 | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -12,6 +12,7 @@ endif | |||
| 12 | 12 | BINARY_NAME=dumber | |
| 13 | 13 | MAIN_PATH=./cmd/dumber | |
| 14 | 14 | DIST_DIR=dist | |
| 15 | + | LOCAL_BIN_DIR?=$(HOME)/.local/bin | |
| 15 | 16 | ||
| 16 | 17 | # Detect number of CPU cores for parallel compilation | |
| 17 | 18 | NPROCS?=$(shell nproc 2>/dev/null || echo 1) | |
| @@ -42,7 +43,7 @@ build: build-frontend ## Build the application (pure Go, no CGO) | |||
| 42 | 43 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 43 | 44 | @mkdir -p $(DIST_DIR) | |
| 44 | 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-helper | |
| 46 | + | @rm -f $(DIST_DIR)/cef-helper | |
| 46 | 47 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 47 | 48 | ||
| 48 | 49 | build-frontend: ## Build homepage and error pages | |
| @@ -54,9 +55,22 @@ build-quick: ## Build without frontend (faster for backend development) | |||
| 54 | 55 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 55 | 56 | @mkdir -p $(DIST_DIR) | |
| 56 | 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-helper | |
| 58 | + | @rm -f $(DIST_DIR)/cef-helper | |
| 58 | 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 | 74 | # Development targets | |
| 61 | 75 | dev: ## Run in development mode | |
| 62 | 76 | @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 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 |
| 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=dumber | 12 | BINARY_NAME=dumber | ||
| 13 | MAIN_PATH=./cmd/dumber | 13 | MAIN_PATH=./cmd/dumber | ||
| 14 | DIST_DIR=dist | 14 | DIST_DIR=dist | ||
| 15 | + | LOCAL_BIN_DIR?=$(HOME)/.local/bin | |||
| 15 | 16 | ||||
| 16 | # Detect number of CPU cores for parallel compilation | 17 | # 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-helper | 46 | + | @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 pages | 49 | 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-helper | 58 | + | @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 targets | 74 | # Development targets | ||
| 61 | dev: ## Run in development mode | 75 | 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 ( | |||
| 18 | 18 | "github.com/bnema/dumber/internal/domain/entity" | |
| 19 | 19 | "github.com/bnema/dumber/internal/domain/repository" | |
| 20 | 20 | "github.com/bnema/dumber/internal/infrastructure/cache" | |
| 21 | + | infracef "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 21 | 22 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 22 | 23 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 23 | 24 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| @@ -117,19 +118,20 @@ func configureBrowserLaunchRelay(cfg *config.Config) { | |||
| 117 | 118 | } | |
| 118 | 119 | ||
| 119 | 120 | 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 | |
| 123 | 122 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | |
| 124 | 123 | // 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 | |
| 127 | 126 | // 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()) | |
| 130 | 130 | os.Exit(1) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | + | enableCrashForensics() | |
| 134 | + | ||
| 133 | 135 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 134 | 136 | // Run GUI mode for browse command | |
| 135 | 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, indicating | |
| 732 | + | // isCEFSubprocess returns true if args contains a --type flag, indicating | |
| 731 | 733 | // 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 { | |
| 734 | 736 | 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 | + | } | |
| 736 | 748 | } | |
| 737 | 749 | } | |
| 738 | 750 | 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 anything | 122 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | ||
| 124 | // else (Cobra, config, arg stripping). We detect subprocesses by the | 123 | // else (Cobra, config, arg stripping). We detect subprocesses by the | ||
| 125 | - | // presence of --type= in os.Args rather than checking the engine env | 124 | + | // presence of a --type flag in os.Args rather than checking the engine |
| 126 | - | // var, because subprocesses may not inherit the environment and the | 125 | + | // 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 command | 136 | // 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, indicating | 732 | + | // 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 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 | + | } | |||
| 736 | } | 748 | } | ||
| 737 | } | 749 | } | ||
| 738 | return false | 750 | return false | ||
M cmd/dumber/main_test.go +47 -0
| @@ -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 | 122 | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |
| 76 | 123 | got, err := resolveCurrentExecutable(func() (string, error) { | |
| 77 | 124 | 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", nil | 124 | return "/usr/bin/dumber", nil | ||
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 9 | 9 | github.com/bnema/purego-pipewire v0.1.2 | |
| 10 | 10 | 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.2 | 6 | 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 |
| 8 | github.com/bnema/purego-ffmpeg v0.3.1 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | ||
| 9 | github.com/bnema/purego-pipewire v0.1.2 | 9 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 10 | github.com/bnema/purego-sqlite v0.1.2 | 10 | 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) { | |||
| 106 | 106 | }, | |
| 107 | 107 | } | |
| 108 | 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 | 113 | default: | |
| 111 | 114 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 112 | 115 | } | |
| @@ -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 { | |||
| 56 | 56 | return app | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 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. | |
| 63 | 63 | func NewSubprocessApp() purecef.App { | |
| 64 | 64 | return &subprocessApp{} | |
| 65 | 65 | } | |
| @@ -122,6 +122,7 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 122 | 122 | useAngle := "" | |
| 123 | 123 | ozonePlatform := "" | |
| 124 | 124 | if commandLine != nil { | |
| 125 | + | appendSwitchIfMissing(commandLine, "no-zygote") | |
| 125 | 126 | processType = commandLine.GetSwitchValue("type") | |
| 126 | 127 | commandLineString = commandLine.GetCommandLineString() | |
| 127 | 128 | useAngle = commandLine.GetSwitchValue("use-angle") | |
| @@ -130,6 +131,13 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 130 | 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 | 141 | func parseRelaunchCommandLineArgs(commandLine string) []string { | |
| 134 | 142 | args := make([]string, 0, 4) | |
| 135 | 143 | var current strings.Builder | |
| @@ -56,10 +56,10 @@ func newDumberApp(engine *Engine) purecef.App { | |||||
| 56 | return app | 56 | return app | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | - | // NewSubprocessApp returns a lightweight raw App implementation for helper | 59 | + | // NewSubprocessApp returns a lightweight raw App implementation for CEF |
| 60 | - | // processes so cef_execute_process can wrap it itself. Keep helper processes | 60 | + | // subprocesses re-executed from the main dumber binary. Keep subprocesses free |
| 61 | - | // free of custom render handlers for now: the renderer bridge introduced a | 61 | + | // 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.Builder | 143 | var current strings.Builder | ||
M internal/infrastructure/cef/app_test.go +78 -21
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "unsafe" | |
| 6 | 7 | ||
| @@ -24,27 +25,56 @@ type relaunchCommandLineStub struct { | |||
| 24 | 25 | commandLineString string | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 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 | + | } | |
| 48 | 78 | ||
| 49 | 79 | func TestRegisterDumbScheme(t *testing.T) { | |
| 50 | 80 | t.Parallel() | |
| @@ -143,3 +173,30 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardNonBrowse(t *testin | |||
| 143 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | 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 string | 25 | 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(){ | |||
| 392 | 392 | // reasserts browser focus when an editable element gains DOM focus. | |
| 393 | 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | |
| 394 | 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 | 396 | function encodeBody(body) { | |
| 406 | 397 | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| @@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 752 | 743 | ||
| 753 | 744 | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 754 | 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 | 757 | // Video playback diagnostic — logs video element state changes. | |
| 758 | 758 | // 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 OSR | 744 | // 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) | |||
| 57 | 57 | require.Contains(t, script, "sendToClipboard(normalized)") | |
| 58 | 58 | require.Contains(t, script, "mirrorClipboardItems(items)") | |
| 59 | 59 | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | |
| 60 | + | require.NotContains(t, script, "__dumberClipboardBridge") | |
| 61 | + | require.NotContains(t, script, "setBridgeNonce") | |
| 60 | 62 | require.Contains(t, script, "test-bridge-nonce") | |
| 61 | 63 | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| @@ -17,7 +18,6 @@ import ( | |||
| 17 | 18 | "github.com/bnema/dumber/assets" | |
| 18 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 19 | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 20 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 22 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 23 | 23 | ) | |
| @@ -25,11 +25,17 @@ import ( | |||
| 25 | 25 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 26 | 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 | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 29 | 35 | func NewEngine( | |
| 30 | 36 | ctx context.Context, | |
| 31 | 37 | opts port.EngineOptions, | |
| 32 | - | profile runtimeprofile.Profile, | |
| 38 | + | paths RuntimePaths, | |
| 33 | 39 | cfg RuntimeConfig, | |
| 34 | 40 | transcodingCfg TranscodingRuntimeConfig, | |
| 35 | 41 | audioFactory port.AudioOutputFactory, | |
| @@ -37,11 +43,11 @@ func NewEngine( | |||
| 37 | 43 | ) (*Engine, error) { | |
| 38 | 44 | logger := logging.FromContext(ctx) | |
| 39 | 45 | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 40 | - | stateRoot := resolvedStateRoot(profile, opts) | |
| 46 | + | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 41 | 47 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 42 | 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 | 51 | if err != nil { | |
| 46 | 52 | 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 | 101 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 96 | 102 | return root | |
| 97 | 103 | } | |
| @@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) | |||
| 101 | 107 | if opts.DataDir != "" { | |
| 102 | 108 | return opts.DataDir | |
| 103 | 109 | } | |
| 104 | - | return profile.CEFUserDataDir() | |
| 110 | + | return defaultStateRoot | |
| 105 | 111 | } | |
| 106 | 112 | ||
| 107 | 113 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 108 | 114 | func prepareCEFSettings( | |
| 109 | 115 | opts port.EngineOptions, | |
| 110 | - | profile runtimeprofile.Profile, | |
| 116 | + | paths RuntimePaths, | |
| 111 | 117 | cfg RuntimeConfig, | |
| 112 | 118 | logger *zerolog.Logger, | |
| 113 | 119 | ) (purecef.Settings, error) { | |
| @@ -126,16 +132,16 @@ func prepareCEFSettings( | |||
| 126 | 132 | settings := purecef.DefaultSettings() | |
| 127 | 133 | settings.MultiThreadedMessageLoop = true | |
| 128 | 134 | settings.ExternalMessagePump = false | |
| 129 | - | settings.RootCachePath = resolvedStateRoot(profile, opts) | |
| 135 | + | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | |
| 130 | 136 | if cfg.CEFDir != "" { | |
| 131 | 137 | 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 | 140 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 135 | 141 | } else { | |
| 136 | 142 | 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 | 145 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | |
| 140 | 146 | } else if bootstrapLogFile != "" { | |
| 141 | 147 | settings.InitTraceFile = bootstrapLogFile | |
| @@ -147,12 +153,6 @@ func prepareCEFSettings( | |||
| 147 | 153 | if cfg.LogSeverity != 0 { | |
| 148 | 154 | 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 | 156 | 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 | 410 | const ( | |
| 426 | 411 | dirPerm = 0o700 | |
| 427 | 412 | filePerm = 0o600 | |
| 428 | 413 | ) | |
| 429 | 414 | ||
| 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 | |
| 436 | 419 | } | |
| 437 | 420 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 438 | 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 | 440 | 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 | 477 | if !puregoCEFInitTraceEnabled() { | |
| 445 | 478 | return "", nil | |
| 446 | 479 | } | |
| 447 | 480 | ||
| 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 | |
| 453 | 484 | } | |
| 454 | 485 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 455 | 486 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | |
| 456 | 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 | 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 | 500 | 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 | 513 | func puregoCEFInitTraceEnabled() bool { | |
| 465 | 514 | return envBoolEnabled(puregoCEFInitTraceEnvVar) | |
| 466 | 515 | } | |
| @@ -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, err | 52 | 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 root | 102 | 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.DataDir | 108 | 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 = true | 133 | settings.MultiThreadedMessageLoop = true | ||
| 128 | settings.ExternalMessagePump = false | 134 | 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.CEFDir | 137 | 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 = runtimeLogFile | 142 | 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 = bootstrapLogFile | 147 | settings.InitTraceFile = bootstrapLogFile | ||
| @@ -147,12 +153,6 @@ func prepareCEFSettings( | |||||
| 147 | if cfg.LogSeverity != 0 { | 153 | if cfg.LogSeverity != 0 { | ||
| 148 | settings.LogSeverity = cfg.LogSeverity | 154 | 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, nil | 156 | 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 = 0o700 | 411 | dirPerm = 0o700 | ||
| 427 | filePerm = 0o600 | 412 | 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 := logFile | 416 | + | 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, nil | 440 | 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 "", nil | 478 | return "", nil | ||
| 446 | } | 479 | } | ||
| 447 | 480 | ||||
| 448 | - | runtimeLogFile := logFile | 481 | + | 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, nil | 500 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | |
| 16 | 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 | 18 | require.Equal(t, "/tmp/cache", root) | |
| 19 | 19 | } | |
| 20 | 20 | ||
| @@ -22,65 +22,113 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |||
| 22 | 22 | profile := testCEFDevProfile(t) | |
| 23 | 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | |
| 24 | 24 | ||
| 25 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 25 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 26 | 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 | 29 | require.Equal(t, "/tmp/override", root) | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |
| 33 | 33 | profile := testCEFProdProfile(t) | |
| 34 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 34 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 35 | 35 | require.Equal(t, profile.CEFUserDataDir(), root) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |
| 39 | 39 | logger := zerolog.Nop() | |
| 40 | 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 | 42 | require.NoError(t, err) | |
| 43 | 43 | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | |
| 44 | 44 | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |
| 45 | + | require.Empty(t, settings.BrowserSubprocessPath) | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 48 | 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 | 52 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |
| 54 | 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 | 59 | require.NoError(t, err) | |
| 57 | 60 | } | |
| 58 | 61 | ||
| 59 | 62 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |
| 60 | 63 | t.Setenv(puregoCEFInitTraceEnvVar, "") | |
| 64 | + | profile := testCEFDevProfile(t) | |
| 61 | 65 | ||
| 62 | - | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") | |
| 66 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 63 | 67 | require.NoError(t, err) | |
| 64 | 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 | 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 | 93 | require.NoError(t, err) | |
| 72 | 94 | require.Equal(t, logFile, path) | |
| 73 | 95 | ||
| 74 | - | info, err := os.Stat(filepath.Dir(logFile)) | |
| 96 | + | fileInfo, err := os.Stat(logFile) | |
| 75 | 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 | 127 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |
| 80 | 128 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 81 | 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 | 132 | require.NoError(t, err) | |
| 85 | 133 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |
| 86 | 134 | require.FileExists(t, path) | |
| @@ -94,11 +142,50 @@ func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |||
| 94 | 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 | 184 | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | |
| 98 | 185 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 99 | 186 | profile := testCEFDevProfile(t) | |
| 100 | 187 | ||
| 101 | - | path, err := prepareCEFInitTraceFile(profile, "") | |
| 188 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 102 | 189 | require.NoError(t, err) | |
| 103 | 190 | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 104 | 191 | 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 ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "math" | |
| 11 | - | "strconv" | |
| 12 | 11 | "strings" | |
| 13 | 12 | "sync" | |
| 14 | 13 | "sync/atomic" | |
| @@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||
| 489 | 488 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | |
| 490 | 489 | const colorScale = 255 | |
| 491 | 490 | ||
| 491 | + | var bridgeNonceRandom = rand.Read | |
| 492 | + | ||
| 492 | 493 | func newBridgeNonce() string { | |
| 493 | 494 | 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 "" | |
| 496 | 497 | } | |
| 497 | 498 | return hex.EncodeToString(buf) | |
| 498 | 499 | } | |
| 499 | 500 | ||
| 500 | 501 | func (wv *WebView) rotateBridgeNonce() string { | |
| 501 | 502 | nonce := newBridgeNonce() | |
| 503 | + | if nonce == "" { | |
| 504 | + | return "" | |
| 505 | + | } | |
| 502 | 506 | wv.mu.Lock() | |
| 503 | 507 | wv.bridgeNonce = nonce | |
| 504 | 508 | 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 = 255 | 489 | 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 = nonce | 507 | 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() { | |||
| 100 | 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 | 117 | function postExplicitCopy(action, text) { | |
| 104 | 118 | if (!text) { | |
| 105 | 119 | return; | |
| @@ -153,9 +167,12 @@ const explicitCopyScript = `(function() { | |||
| 153 | 167 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |
| 154 | 168 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |
| 155 | 169 | navigator.clipboard.writeText = function(text) { | |
| 170 | + | var userActivated = hasUserActivation(); | |
| 156 | 171 | return Promise.resolve(originalWriteText(text)).then(function(result) { | |
| 157 | 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 | 176 | return result; | |
| 160 | 177 | }); | |
| 161 | 178 | }; | |
| @@ -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) { | |||
| 39 | 39 | assert.Contains(t, script, "document.addEventListener('cut'") | |
| 40 | 40 | assert.Contains(t, script, "document.execCommand") | |
| 41 | 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 | 45 | assert.Contains(t, script, "try {") | |
| 43 | 46 | assert.Contains(t, script, "finally {") | |
| 44 | 47 | 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") | ||