Merge branch 'main' into feat/systemviews-wasm
# Conflicts: # .github/workflows/ci.yml # Makefile # internal/bootstrap/engine.go # internal/infrastructure/cef/internal_urls.go # internal/ui/app.go
202 files · +22532 -2558
M .github/workflows/aur.yml +2 -2
| @@ -98,7 +98,7 @@ jobs: | |||
| 98 | 98 | cat pkgbuild/dumber-browser-git/PKGBUILD | |
| 99 | 99 | ||
| 100 | 100 | - name: Publish dumber-browser-git to AUR | |
| 101 | - | uses: KSXGitHub/github-actions-deploy-aur@v4.1.2 | |
| 101 | + | uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 | |
| 102 | 102 | with: | |
| 103 | 103 | pkgname: dumber-browser-git | |
| 104 | 104 | pkgbuild: ./pkgbuild/dumber-browser-git/PKGBUILD | |
| @@ -216,7 +216,7 @@ jobs: | |||
| 216 | 216 | cat pkgbuild/dumber-browser-bin/PKGBUILD | |
| 217 | 217 | ||
| 218 | 218 | - name: Publish dumber-browser-bin to AUR | |
| 219 | - | uses: KSXGitHub/github-actions-deploy-aur@v4.1.2 | |
| 219 | + | uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 | |
| 220 | 220 | with: | |
| 221 | 221 | pkgname: dumber-browser-bin | |
| 222 | 222 | pkgbuild: ./pkgbuild/dumber-browser-bin/PKGBUILD | |
M .github/workflows/ci.yml +27 -16
| @@ -12,25 +12,34 @@ on: | |||
| 12 | 12 | - 'sqlc.yaml' | |
| 13 | 13 | - 'assets/**' | |
| 14 | 14 | - '.github/workflows/**' | |
| 15 | - | - '!**.md' | |
| 16 | - | - '!docs/**' | |
| 17 | - | - '!LICENSE' | |
| 18 | 15 | pull_request: | |
| 19 | - | paths: | |
| 20 | - | - '**.go' | |
| 21 | - | - 'go.mod' | |
| 22 | - | - 'go.sum' | |
| 23 | - | - '.golangci.yml' | |
| 24 | - | - 'Makefile' | |
| 25 | - | - 'sqlc.yaml' | |
| 26 | - | - 'assets/**' | |
| 27 | - | - '.github/workflows/**' | |
| 28 | - | - '!**.md' | |
| 29 | - | - '!docs/**' | |
| 30 | - | - '!LICENSE' | |
| 31 | 16 | ||
| 32 | 17 | jobs: | |
| 18 | + | changes: | |
| 19 | + | runs-on: ubuntu-latest | |
| 20 | + | outputs: | |
| 21 | + | ci: ${{ steps.filter.outputs.ci }} | |
| 22 | + | steps: | |
| 23 | + | - uses: actions/checkout@v6 | |
| 24 | + | with: | |
| 25 | + | fetch-depth: 0 | |
| 26 | + | - uses: dorny/paths-filter@v4 | |
| 27 | + | id: filter | |
| 28 | + | with: | |
| 29 | + | filters: | | |
| 30 | + | ci: | |
| 31 | + | - '**.go' | |
| 32 | + | - 'go.mod' | |
| 33 | + | - 'go.sum' | |
| 34 | + | - '.golangci.yml' | |
| 35 | + | - 'Makefile' | |
| 36 | + | - 'sqlc.yaml' | |
| 37 | + | - 'assets/**' | |
| 38 | + | - '.github/workflows/**' | |
| 39 | + | ||
| 33 | 40 | lint: | |
| 41 | + | needs: changes | |
| 42 | + | if: needs.changes.outputs.ci == 'true' | |
| 34 | 43 | runs-on: ubuntu-latest | |
| 35 | 44 | container: | |
| 36 | 45 | image: archlinux:latest | |
| @@ -58,13 +67,15 @@ jobs: | |||
| 58 | 67 | run: make build-systemviews | |
| 59 | 68 | - name: Install golangci-lint | |
| 60 | 69 | run: | | |
| 61 | - | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.9.0 | |
| 70 | + | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest | |
| 62 | 71 | - name: Run golangci-lint | |
| 63 | 72 | run: | | |
| 64 | 73 | git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| 65 | 74 | golangci-lint run --timeout=20m | |
| 66 | 75 | ||
| 67 | 76 | test: | |
| 77 | + | needs: changes | |
| 78 | + | if: needs.changes.outputs.ci == 'true' | |
| 68 | 79 | runs-on: ubuntu-latest | |
| 69 | 80 | container: | |
| 70 | 81 | image: archlinux:latest | |
M .github/workflows/dependabot-auto-merge.yml +1 -1
| @@ -13,7 +13,7 @@ jobs: | |||
| 13 | 13 | steps: | |
| 14 | 14 | - name: Dependabot metadata | |
| 15 | 15 | id: metadata | |
| 16 | - | uses: dependabot/fetch-metadata@v2 | |
| 16 | + | uses: dependabot/fetch-metadata@v3 | |
| 17 | 17 | with: | |
| 18 | 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" | |
| 19 | 19 | ||
M .github/workflows/flatpak.yml +1 -1
| @@ -116,7 +116,7 @@ jobs: | |||
| 116 | 116 | cache-key: flatpak-builder-${{ steps.version.outputs.version }} | |
| 117 | 117 | ||
| 118 | 118 | - name: Upload Flatpak to Release | |
| 119 | - | uses: softprops/action-gh-release@v2 | |
| 119 | + | uses: softprops/action-gh-release@v3 | |
| 120 | 120 | with: | |
| 121 | 121 | tag_name: ${{ steps.version.outputs.version }} | |
| 122 | 122 | files: dumber.flatpak | |
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` | |
M .mockery.yaml +11 -1
| @@ -42,13 +42,18 @@ packages: | |||
| 42 | 42 | FilterApplier: {} | |
| 43 | 43 | FaviconDatabase: {} | |
| 44 | 44 | IdleInhibitor: {} | |
| 45 | - | PopupCapable: {} | |
| 45 | + | PopupLifecycleCapable: {} | |
| 46 | + | PopupOpenerCapable: {} | |
| 46 | 47 | OAuthCallbackCapable: {} | |
| 47 | 48 | DevToolsOpener: {} | |
| 48 | 49 | Printer: {} | |
| 49 | 50 | AccentKeyHandler: {} | |
| 50 | 51 | AutoCopyConfig: {} | |
| 51 | 52 | Clipboard: {} | |
| 53 | + | ClipboardTextOrchestrator: {} | |
| 54 | + | ContextMenuBuilder: {} | |
| 55 | + | ContextMenuActionExecutor: {} | |
| 56 | + | ContextMenuActionExecutorFactory: {} | |
| 52 | 57 | DesktopIntegration: {} | |
| 53 | 58 | DownloadEventHandler: {} | |
| 54 | 59 | DownloadPreparer: {} | |
| @@ -71,6 +76,11 @@ packages: | |||
| 71 | 76 | RuntimeVersionProbe: {} | |
| 72 | 77 | ScriptRefresher: {} | |
| 73 | 78 | SnapshotService: {} | |
| 79 | + | BrowserWindowOpener: {} | |
| 80 | + | BrowserLaunchRelay: {} | |
| 81 | + | ImageDataResolver: {} | |
| 82 | + | ResolvedImageSaver: {} | |
| 83 | + | MenuActionDelegator: {} | |
| 74 | 84 | TextInputTargetProvider: {} | |
| 75 | 85 | Texture: {} | |
| 76 | 86 | ToolkitAvailabilityNotifier: {} | |
M CHANGELOG.md +18 -0
| @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ### Added | |
| 8 | + | ||
| 9 | + | - **Standalone omnibox launcher**: `dumber omnibox` now opens the omnibox in a standalone window. | |
| 10 | + | - **CEF engine support**: The CEF engine is now available alongside WebKit, including audio output support. | |
| 11 | + | - **Fresh browser windows**: Browser launches can now open in dedicated fresh windows. | |
| 12 | + | - **Unified context menus**: Right-click menus now behave consistently across WebKit and CEF. | |
| 13 | + | - **Shared download handling**: Downloads now use the same handling in WebKit and CEF, with progress and completion toasts. | |
| 14 | + | ||
| 15 | + | ### Changed | |
| 16 | + | ||
| 17 | + | - **Omnibox default order toggle**: You can now persist and switch the default history order. | |
| 18 | + | - **Omnibox most-visited window**: The most-visited view now uses a 30-day history window by default. | |
| 19 | + | ||
| 20 | + | ### Fixed | |
| 21 | + | ||
| 22 | + | - **CEF storage location**: CEF user data now uses the XDG data directory. | |
| 23 | + | - **CEF clipboard and focus**: Clipboard and input focus handling in CEF is more reliable. | |
| 24 | + | ||
| 7 | 25 | ## [0.28.0] - 2026-03-27 | |
| 8 | 26 | ||
| 9 | 27 | ### Added | |
M Makefile +17 -3
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-systemviews 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-systemviews 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-systemviews ## 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-systemviews: ## Build the WASM systemviews runtime | |
| @@ -56,9 +57,22 @@ build-quick: ## Build quickly for backend development | |||
| 56 | 57 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 57 | 58 | @mkdir -p $(DIST_DIR) | |
| 58 | 59 | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 59 | - | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | |
| 60 | + | @rm -f $(DIST_DIR)/cef-helper | |
| 60 | 61 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 61 | 62 | ||
| 63 | + | install-local: build-quick ## Install dumber to ~/.local/bin atomically | |
| 64 | + | @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..." | |
| 65 | + | @mkdir -p $(LOCAL_BIN_DIR) | |
| 66 | + | @tmp_dumber="$$(mktemp '$(LOCAL_BIN_DIR)/.dumber.tmp.XXXXXX')"; \ | |
| 67 | + | trap 'rm -f "$$tmp_dumber"' EXIT INT TERM; \ | |
| 68 | + | install -m 0755 $(DIST_DIR)/$(BINARY_NAME) "$$tmp_dumber"; \ | |
| 69 | + | mv -f "$$tmp_dumber" $(LOCAL_BIN_DIR)/$(BINARY_NAME); \ | |
| 70 | + | removed_stale=0; \ | |
| 71 | + | if [ -e $(LOCAL_BIN_DIR)/cef-helper ]; then rm -f $(LOCAL_BIN_DIR)/cef-helper && removed_stale=1; fi; \ | |
| 72 | + | trap - EXIT INT TERM; \ | |
| 73 | + | echo "Installed: $(LOCAL_BIN_DIR)/$(BINARY_NAME)"; \ | |
| 74 | + | if [ "$$removed_stale" -eq 1 ]; then echo "Removed stale: $(LOCAL_BIN_DIR)/cef-helper"; fi | |
| 75 | + | ||
| 62 | 76 | # Development targets | |
| 63 | 77 | dev: ## Run in development mode | |
| 64 | 78 | @echo "Running in development mode..." | |
D cmd/cef-helper/main.go +0 -32
| @@ -1,32 +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 | - | cef "github.com/bnema/purego-cef/cef" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | func main() { | |
| 20 | - | // Pin to the main OS thread — CEF requires it. | |
| 21 | - | runtime.LockOSThread() | |
| 22 | - | ||
| 23 | - | // cef_execute_process will handle the subprocess lifecycle and call | |
| 24 | - | // os.Exit internally if this is a valid CEF subprocess invocation. | |
| 25 | - | // If it returns (code < 0), this is not a subprocess — shouldn't | |
| 26 | - | // happen in normal operation since CEF only launches this binary | |
| 27 | - | // with --type= flags. | |
| 28 | - | cef.MaybeExitSubprocess() | |
| 29 | - | ||
| 30 | - | // If we get here, something went wrong. | |
| 31 | - | os.Exit(1) | |
| 32 | - | } | |
M cmd/dumber/main.go +158 -62
| @@ -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" | |
| @@ -26,6 +27,7 @@ import ( | |||
| 26 | 27 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 27 | 28 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 28 | 29 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 30 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 29 | 31 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 30 | 32 | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| 31 | 33 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| @@ -53,6 +55,9 @@ var initialURL string | |||
| 53 | 55 | // restoreSessionID holds the session ID to restore on startup. | |
| 54 | 56 | var restoreSessionID string | |
| 55 | 57 | ||
| 58 | + | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | |
| 59 | + | var browserLaunchRelay port.BrowserLaunchRelay | |
| 60 | + | ||
| 56 | 61 | type launchMode string | |
| 57 | 62 | ||
| 58 | 63 | const ( | |
| @@ -86,27 +91,78 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 86 | 91 | return launchModeCLI, "" | |
| 87 | 92 | } | |
| 88 | 93 | ||
| 89 | - | func main() { | |
| 90 | - | enableCrashForensics() | |
| 94 | + | func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) { | |
| 95 | + | if relay == nil || browseURL == "" { | |
| 96 | + | return false, nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | return relay.DeliverOpenFreshWindow(ctx, browseURL) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) { | |
| 103 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |
| 104 | + | if err != nil { | |
| 105 | + | return nil, err | |
| 106 | + | } | |
| 107 | + | return desktop.NewBrowserLaunchRelay(profile.IPC), nil | |
| 108 | + | } | |
| 91 | 109 | ||
| 92 | - | // CEF subprocess handling: when CEF re-launches the binary with | |
| 110 | + | func configureBrowserLaunchRelay(cfg *config.Config) { | |
| 111 | + | relay, err := resolveBrowserLaunchRelay(cfg) | |
| 112 | + | if err != nil { | |
| 113 | + | fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err) | |
| 114 | + | browserLaunchRelay = nil | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | browserLaunchRelay = relay | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func main() { | |
| 121 | + | // CEF subprocess handling: when CEF re-launches this binary with | |
| 93 | 122 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | |
| 94 | - | // else (Cobra, config, arg stripping). We detect subprocesses by the | |
| 95 | - | // presence of --type= in os.Args rather than checking the engine env | |
| 96 | - | // var, because subprocesses may not inherit the environment and the | |
| 97 | - | // engine can also be selected via config. | |
| 98 | - | if isCEFSubprocess() { | |
| 99 | - | cef.MaybeExitSubprocess() | |
| 100 | - | os.Exit(1) | |
| 123 | + | // else (Cobra, config, arg stripping). We only treat a leading Chromium | |
| 124 | + | // helper-process --type flag as a subprocess marker rather than scanning | |
| 125 | + | // the full argv, so normal user commands like `dumber browse --type=foo` | |
| 126 | + | // are not misclassified. | |
| 127 | + | if isCEFSubprocess(os.Args) { | |
| 128 | + | runtime.LockOSThread() | |
| 129 | + | executed, exitCode, err := cef.ExecuteSubprocessWithApp(infracef.NewSubprocessApp()) | |
| 130 | + | if err != nil { | |
| 131 | + | _, _ = fmt.Fprintf(os.Stderr, "cef: ExecuteSubprocessWithApp: %v\n", err) | |
| 132 | + | os.Exit(1) | |
| 133 | + | } | |
| 134 | + | if executed { | |
| 135 | + | os.Exit(exitCode) | |
| 136 | + | } | |
| 137 | + | runtime.UnlockOSThread() | |
| 138 | + | // If Chromium-style helper args were present but purego-cef declined to | |
| 139 | + | // handle them as a subprocess, drop them before normal startup so Cobra | |
| 140 | + | // and the GUI bootstrap do not choke on unknown --type/... flags. | |
| 141 | + | os.Args = os.Args[:1] | |
| 101 | 142 | } | |
| 102 | 143 | ||
| 144 | + | enableCrashForensics() | |
| 145 | + | ||
| 103 | 146 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 104 | 147 | // Run GUI mode for browse command | |
| 105 | 148 | if mode == launchModeBrowse { | |
| 149 | + | cfg := initConfig() | |
| 150 | + | configureBrowserLaunchRelay(cfg) | |
| 151 | + | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | |
| 152 | + | fmt.Fprintf( | |
| 153 | + | os.Stderr, | |
| 154 | + | "warning: failed to forward browse URL %q to a running instance, falling back to a new process: %v\n", | |
| 155 | + | browseURL, | |
| 156 | + | err, | |
| 157 | + | ) | |
| 158 | + | } else if forwarded { | |
| 159 | + | os.Exit(0) | |
| 160 | + | } | |
| 161 | + | ||
| 106 | 162 | initialURL = browseURL | |
| 107 | 163 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 108 | 164 | os.Args = os.Args[:1] | |
| 109 | - | os.Exit(runGUI()) | |
| 165 | + | os.Exit(runGUI(cfg)) | |
| 110 | 166 | return | |
| 111 | 167 | } | |
| 112 | 168 | ||
| @@ -127,14 +183,17 @@ func main() { | |||
| 127 | 183 | cmd.Execute() | |
| 128 | 184 | } | |
| 129 | 185 | ||
| 130 | - | func runGUI() int { | |
| 186 | + | func runGUI(cfg *config.Config) int { | |
| 131 | 187 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 132 | 188 | ||
| 133 | 189 | runtime.LockOSThread() | |
| 134 | 190 | component.SetSkeletonVersion(version) | |
| 135 | 191 | timer := bootstrap.NewStartupTimer() | |
| 136 | 192 | ||
| 137 | - | cfg := initConfig() | |
| 193 | + | if cfg == nil { | |
| 194 | + | cfg = initConfig() | |
| 195 | + | configureBrowserLaunchRelay(cfg) | |
| 196 | + | } | |
| 138 | 197 | timer.Mark("config") | |
| 139 | 198 | ||
| 140 | 199 | ctx := initStartupContextWithTrace(cfg) | |
| @@ -187,6 +246,13 @@ func runGUI() int { | |||
| 187 | 246 | log.Error().Err(err).Msg("failed to create application") | |
| 188 | 247 | return 1 | |
| 189 | 248 | } | |
| 249 | + | if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok { | |
| 250 | + | relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) { | |
| 251 | + | if err := app.OpenFreshWindow(ctx, url); err != nil { | |
| 252 | + | log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window") | |
| 253 | + | } | |
| 254 | + | }) | |
| 255 | + | } | |
| 190 | 256 | timer.Mark("ui_deps") | |
| 191 | 257 | timer.Log(ctx) | |
| 192 | 258 | ||
| @@ -203,6 +269,7 @@ func runStandaloneOmnibox() int { | |||
| 203 | 269 | component.SetSkeletonVersion(version) | |
| 204 | 270 | ||
| 205 | 271 | cfg := initConfig() | |
| 272 | + | configureBrowserLaunchRelay(cfg) | |
| 206 | 273 | ctx := initStartupContextWithTrace(cfg) | |
| 207 | 274 | ||
| 208 | 275 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| @@ -212,9 +279,15 @@ func runStandaloneOmnibox() int { | |||
| 212 | 279 | ||
| 213 | 280 | preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized) | |
| 214 | 281 | ||
| 215 | - | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |
| 282 | + | // Standalone omnibox intentionally skips browser-engine initialization. | |
| 283 | + | // When the main browser is already running under CEF, spinning up another CEF | |
| 284 | + | // engine for the omnibox process triggers Chromium's already-running-app | |
| 285 | + | // relaunch path and can turn the `omnibox` subcommand into a bogus browser | |
| 286 | + | // URL (`omnibox/`). The standalone omnibox only needs repos/use cases/theme; | |
| 287 | + | // actual browser navigation is handed off via LaunchBrowserURL. | |
| 288 | + | repos, dbCleanup, err := initStandaloneOmniboxRepos() | |
| 216 | 289 | if err != nil { | |
| 217 | - | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime") | |
| 290 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox repositories") | |
| 218 | 291 | return 1 | |
| 219 | 292 | } | |
| 220 | 293 | if dbCleanup != nil { | |
| @@ -225,10 +298,11 @@ func runStandaloneOmnibox() int { | |||
| 225 | 298 | uiDeps, err := buildUIDependencies( | |
| 226 | 299 | ctx, | |
| 227 | 300 | cfg, | |
| 301 | + | initResult.RuntimeProfile, | |
| 228 | 302 | initResult.ThemeManager, | |
| 229 | 303 | initResult.ColorResolver, | |
| 230 | 304 | initResult.AdwaitaDetector, | |
| 231 | - | engine, | |
| 305 | + | nil, | |
| 232 | 306 | repos, | |
| 233 | 307 | useCases, | |
| 234 | 308 | nil, | |
| @@ -239,7 +313,7 @@ func runStandaloneOmnibox() int { | |||
| 239 | 313 | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies") | |
| 240 | 314 | return 1 | |
| 241 | 315 | } | |
| 242 | - | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |
| 316 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, nil) | |
| 243 | 317 | ||
| 244 | 318 | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |
| 245 | 319 | } | |
| @@ -356,7 +430,7 @@ func buildAndConfigureApp( | |||
| 356 | 430 | browserSession *bootstrap.BrowserSession, | |
| 357 | 431 | ) (*ui.App, error) { | |
| 358 | 432 | uiDeps, err := buildUIDependencies( | |
| 359 | - | ctx, cfg, initResult.ThemeManager, | |
| 433 | + | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, | |
| 360 | 434 | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 361 | 435 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | |
| 362 | 436 | ) | |
| @@ -392,12 +466,11 @@ func initStackAndRepos( | |||
| 392 | 466 | if needsEagerDB { | |
| 393 | 467 | // Parallel phase 2: Database + engine initialize concurrently | |
| 394 | 468 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | |
| 395 | - | Ctx: ctx, | |
| 396 | - | Config: cfg, | |
| 397 | - | DataDir: initResult.DataDir, | |
| 398 | - | CacheDir: initResult.CacheDir, | |
| 399 | - | ThemeManager: initResult.ThemeManager, | |
| 400 | - | ColorResolver: initResult.ColorResolver, | |
| 469 | + | Ctx: ctx, | |
| 470 | + | Config: cfg, | |
| 471 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 472 | + | ThemeManager: initResult.ThemeManager, | |
| 473 | + | ColorResolver: initResult.ColorResolver, | |
| 401 | 474 | }) | |
| 402 | 475 | if err != nil { | |
| 403 | 476 | return nil, nil, nil, err | |
| @@ -407,24 +480,31 @@ func initStackAndRepos( | |||
| 407 | 480 | ||
| 408 | 481 | log := logging.FromContext(ctx) | |
| 409 | 482 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | |
| 410 | - | Ctx: ctx, | |
| 411 | - | Config: cfg, | |
| 412 | - | DataDir: initResult.DataDir, | |
| 413 | - | CacheDir: initResult.CacheDir, | |
| 414 | - | ThemeManager: initResult.ThemeManager, | |
| 415 | - | ColorResolver: initResult.ColorResolver, | |
| 416 | - | Logger: *log, | |
| 483 | + | Ctx: ctx, | |
| 484 | + | Config: cfg, | |
| 485 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 486 | + | ThemeManager: initResult.ThemeManager, | |
| 487 | + | ColorResolver: initResult.ColorResolver, | |
| 488 | + | Logger: *log, | |
| 417 | 489 | }) | |
| 418 | 490 | if err != nil { | |
| 419 | 491 | return nil, nil, nil, err | |
| 420 | 492 | } | |
| 421 | 493 | ||
| 422 | - | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 494 | + | repos, dbCleanup, err := initStandaloneOmniboxRepos() | |
| 423 | 495 | if err != nil { | |
| 424 | 496 | return nil, nil, nil, err | |
| 425 | 497 | } | |
| 498 | + | return engine, repos, dbCleanup, nil | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func initStandaloneOmniboxRepos() (*repositories, func(), error) { | |
| 502 | + | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 503 | + | if err != nil { | |
| 504 | + | return nil, nil, err | |
| 505 | + | } | |
| 426 | 506 | dbCleanup := func() { _ = lazyDB.Close() } | |
| 427 | - | return engine, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 507 | + | return createLazyRepositories(lazyDB), dbCleanup, nil | |
| 428 | 508 | } | |
| 429 | 509 | ||
| 430 | 510 | func configureDeferredInit( | |
| @@ -660,20 +740,29 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 660 | 740 | } | |
| 661 | 741 | } | |
| 662 | 742 | ||
| 663 | - | // isCEFSubprocess returns true if os.Args contains a --type= flag, indicating | |
| 664 | - | // this process was spawned by CEF as a renderer, GPU, or utility subprocess. | |
| 665 | - | func isCEFSubprocess() bool { | |
| 666 | - | for _, arg := range os.Args { | |
| 667 | - | if strings.HasPrefix(arg, "--type=") { | |
| 668 | - | return true | |
| 669 | - | } | |
| 743 | + | // isCEFSubprocess returns true when argv begins with a Chromium helper-process | |
| 744 | + | // --type flag, indicating this process was spawned by CEF as a renderer, GPU, | |
| 745 | + | // or utility subprocess. | |
| 746 | + | func isCEFSubprocess(args []string) bool { | |
| 747 | + | if len(args) < 2 { | |
| 748 | + | return false | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | if strings.HasPrefix(args[1], "--type=") { | |
| 752 | + | value := strings.TrimPrefix(args[1], "--type=") | |
| 753 | + | return value != "" && !strings.HasPrefix(value, "-") | |
| 670 | 754 | } | |
| 671 | - | return false | |
| 755 | + | ||
| 756 | + | return args[1] == "--type" && | |
| 757 | + | len(args) > 2 && | |
| 758 | + | args[2] != "" && | |
| 759 | + | !strings.HasPrefix(args[2], "-") | |
| 672 | 760 | } | |
| 673 | 761 | ||
| 674 | 762 | func buildUIDependencies( | |
| 675 | 763 | ctx context.Context, | |
| 676 | 764 | cfg *config.Config, | |
| 765 | + | runtimeProfile runtimeprofile.Profile, | |
| 677 | 766 | themeManager *theme.Manager, | |
| 678 | 767 | colorResolver port.ColorSchemeResolver, | |
| 679 | 768 | adwaitaDetector port.ToolkitAvailabilityNotifier, | |
| @@ -695,6 +784,7 @@ func buildUIDependencies( | |||
| 695 | 784 | } | |
| 696 | 785 | ||
| 697 | 786 | focusProvider := textinput.NewFocusProvider() | |
| 787 | + | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |
| 698 | 788 | ||
| 699 | 789 | uiDeps := &ui.Dependencies{ | |
| 700 | 790 | Ctx: ctx, | |
| @@ -705,23 +795,26 @@ func buildUIDependencies( | |||
| 705 | 795 | Theme: themeManager, | |
| 706 | 796 | ColorResolver: colorResolver, | |
| 707 | 797 | AdwaitaDetector: adwaitaDetector, | |
| 708 | - | XDG: xdg.New(), | |
| 709 | - | Engine: engine, | |
| 710 | - | FilterManager: filterManager, | |
| 711 | - | HistoryRepo: repos.history, | |
| 712 | - | FavoriteRepo: repos.favorite, | |
| 713 | - | ZoomRepo: repos.zoom, | |
| 714 | - | PermissionRepo: repos.permission, | |
| 715 | - | TabsUC: uc.tabs, | |
| 716 | - | PanesUC: uc.panes, | |
| 717 | - | HistoryUC: uc.history, | |
| 718 | - | FavoritesUC: uc.favorites, | |
| 719 | - | ZoomUC: uc.zoom, | |
| 720 | - | PermissionUC: uc.permission, | |
| 721 | - | NavigateUC: uc.navigate, | |
| 722 | - | CopyURLUC: uc.copyURL, | |
| 723 | - | Clipboard: uc.clipboard, | |
| 724 | - | FaviconService: uc.favicon, | |
| 798 | + | XDG: xdg.New( | |
| 799 | + | runtimeProfile.Mode == runtimeprofile.ModeDev, | |
| 800 | + | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | |
| 801 | + | ), | |
| 802 | + | Engine: engine, | |
| 803 | + | FilterManager: filterManager, | |
| 804 | + | HistoryRepo: repos.history, | |
| 805 | + | FavoriteRepo: repos.favorite, | |
| 806 | + | ZoomRepo: repos.zoom, | |
| 807 | + | PermissionRepo: repos.permission, | |
| 808 | + | TabsUC: uc.tabs, | |
| 809 | + | PanesUC: uc.panes, | |
| 810 | + | HistoryUC: uc.history, | |
| 811 | + | FavoritesUC: uc.favorites, | |
| 812 | + | ZoomUC: uc.zoom, | |
| 813 | + | PermissionUC: uc.permission, | |
| 814 | + | NavigateUC: uc.navigate, | |
| 815 | + | CopyURLUC: uc.copyURL, | |
| 816 | + | Clipboard: uc.clipboard, | |
| 817 | + | FaviconService: uc.favicon, | |
| 725 | 818 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | |
| 726 | 819 | IsInternalURL: favicon.IsInternalURL, | |
| 727 | 820 | InternalDomain: favicon.InternalDomain, | |
| @@ -738,7 +831,7 @@ func buildUIDependencies( | |||
| 738 | 831 | }, | |
| 739 | 832 | CheckUpdateUC: uc.checkUpdate, | |
| 740 | 833 | ApplyUpdateUC: uc.applyUpdate, | |
| 741 | - | SessionSpawner: desktop.NewSessionSpawner(ctx), | |
| 834 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | |
| 742 | 835 | FileSystem: filesystem.New(), | |
| 743 | 836 | AccentFocusProvider: focusProvider, | |
| 744 | 837 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | |
| @@ -759,9 +852,12 @@ func buildUIDependencies( | |||
| 759 | 852 | return config.GetManager().Watch() | |
| 760 | 853 | }, | |
| 761 | 854 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 762 | - | LaunchBrowserURL: desktop.LaunchBrowserURL, | |
| 763 | - | ConfigMigrator: config.NewMigrator(), | |
| 764 | - | HandlerDeps: *handlerDeps, | |
| 855 | + | LaunchBrowserURL: func(uri string) { | |
| 856 | + | browserLauncher.LaunchURL(ctx, uri) | |
| 857 | + | }, | |
| 858 | + | BrowserLaunchRelay: browserLaunchRelay, | |
| 859 | + | ConfigMigrator: config.NewMigrator(), | |
| 860 | + | HandlerDeps: *handlerDeps, | |
| 765 | 861 | } | |
| 766 | 862 | ||
| 767 | 863 | return uiDeps, nil | |
M cmd/dumber/main_test.go +107 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 7 | 9 | "github.com/bnema/dumber/internal/bootstrap" | |
| 8 | 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 9 | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -70,6 +72,68 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) { | |||
| 70 | 72 | } | |
| 71 | 73 | } | |
| 72 | 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 | + | name: "type flag after user command", | |
| 113 | + | args: []string{"dumber", "browse", "--type=renderer"}, | |
| 114 | + | want: false, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "type flag not in second argv slot", | |
| 118 | + | args: []string{"dumber", "browse", "https://example.com", "--type", "renderer"}, | |
| 119 | + | want: false, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "equals form with dash-prefixed value", | |
| 123 | + | args: []string{"dumber", "--type=--renderer"}, | |
| 124 | + | want: false, | |
| 125 | + | }, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tc := range tests { | |
| 129 | + | t.Run(tc.name, func(t *testing.T) { | |
| 130 | + | if got := isCEFSubprocess(tc.args); got != tc.want { | |
| 131 | + | t.Fatalf("expected %v, got %v", tc.want, got) | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 73 | 137 | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |
| 74 | 138 | got, err := resolveCurrentExecutable(func() (string, error) { | |
| 75 | 139 | return "/usr/bin/dumber", nil | |
| @@ -131,3 +195,46 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) { | |||
| 131 | 195 | t.Fatal("expected adwaita detector to remain unavailable") | |
| 132 | 196 | } | |
| 133 | 197 | } | |
| 198 | + | ||
| 199 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) { | |
| 200 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 201 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |
| 202 | + | ||
| 203 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 204 | + | ||
| 205 | + | if err != nil { | |
| 206 | + | t.Fatalf("expected no error, got %v", err) | |
| 207 | + | } | |
| 208 | + | if !forwarded { | |
| 209 | + | t.Fatal("expected browse URL to be forwarded") | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) { | |
| 214 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 215 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |
| 216 | + | ||
| 217 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 218 | + | ||
| 219 | + | if err != nil { | |
| 220 | + | t.Fatalf("expected no error, got %v", err) | |
| 221 | + | } | |
| 222 | + | if forwarded { | |
| 223 | + | t.Fatal("expected browse URL to remain unforwarded") | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) { | |
| 228 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 229 | + | wantErr := errors.New("relay error") | |
| 230 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr) | |
| 231 | + | ||
| 232 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 233 | + | ||
| 234 | + | if !errors.Is(err, wantErr) { | |
| 235 | + | t.Fatalf("expected error %v, got %v", wantErr, err) | |
| 236 | + | } | |
| 237 | + | if forwarded { | |
| 238 | + | t.Fatal("expected forwarded to be false on error") | |
| 239 | + | } | |
| 240 | + | } | |
M docs/config/index.md +2 -0
| @@ -104,12 +104,14 @@ dumber dmenu --days 14 --most-visited | |||
| 104 | 104 | | Key | Type | Default | Valid Values | Description | | |
| 105 | 105 | |-----|------|---------|--------------|-------------| | |
| 106 | 106 | | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior | | |
| 107 | + | | `omnibox.most_visited_days` | int | `30` | `>= 0` | Days of history to consider when `initial_behavior = "most_visited"` (`0` = all history) | | |
| 107 | 108 | | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane | | |
| 108 | 109 | ||
| 109 | 110 | **Example:** | |
| 110 | 111 | ```toml | |
| 111 | 112 | [omnibox] | |
| 112 | 113 | initial_behavior = "recent" # Show recent history when omnibox opens | |
| 114 | + | most_visited_days = 30 # Days of history used for most_visited | |
| 113 | 115 | auto_open_on_new_pane = false | |
| 114 | 116 | ||
| 115 | 117 | # Alternative options: | |
M docs/reference/configuration.md +1 -0
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | | `dmenu.date_format` | string | `2006-01-02 15:04` | Go time format | | |
| 19 | 19 | | `dmenu.sort_by_visit_count` | bool | `true` | | | |
| 20 | 20 | | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` | | |
| 21 | + | | `omnibox.most_visited_days` | int | `30` | `>= 0` | | |
| 21 | 22 | | `omnibox.auto_open_on_new_pane` | bool | `false` | | | |
| 22 | 23 | | `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` | | |
| 23 | 24 | | `logging.format` | string | `text` | `text`, `json` | | |
M go.mod +10 -13
| @@ -3,27 +3,26 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.26.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/purego-cef v0.8.1 | |
| 7 | - | github.com/bnema/purego-ffmpeg v0.3.0 | |
| 8 | - | github.com/bnema/purego-pipewire v0.1.1 | |
| 9 | - | github.com/bnema/purego-sqlite v0.1.1 | |
| 10 | - | github.com/bnema/puregotk v0.3.0 | |
| 11 | - | github.com/bnema/puregotk-webkit v0.0.16 | |
| 6 | + | github.com/bnema/purego v0.11.0-bnema.2 | |
| 7 | + | github.com/bnema/purego-cef v0.10.2 | |
| 8 | + | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 9 | + | github.com/bnema/purego-pipewire v0.1.2 | |
| 10 | + | github.com/bnema/purego-sqlite v0.1.2 | |
| 11 | + | github.com/bnema/puregotk v0.5.1 | |
| 12 | 12 | github.com/charmbracelet/bubbles v1.0.0 | |
| 13 | 13 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 14 | 14 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 15 | - | github.com/ebitengine/purego v0.10.0 | |
| 16 | 15 | github.com/fsnotify/fsnotify v1.9.0 | |
| 17 | 16 | github.com/godbus/dbus/v5 v5.2.2 | |
| 18 | 17 | github.com/google/uuid v1.6.0 | |
| 19 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | |
| 20 | 19 | github.com/pressly/goose/v3 v3.27.0 | |
| 21 | - | github.com/rs/zerolog v1.34.0 | |
| 20 | + | github.com/rs/zerolog v1.35.1 | |
| 22 | 21 | github.com/spf13/cobra v1.10.2 | |
| 23 | 22 | github.com/spf13/viper v1.21.0 | |
| 24 | 23 | github.com/stretchr/testify v1.11.1 | |
| 25 | - | golang.org/x/image v0.38.0 | |
| 26 | - | golang.org/x/sys v0.42.0 | |
| 24 | + | golang.org/x/image v0.39.0 | |
| 25 | + | golang.org/x/sys v0.43.0 | |
| 27 | 26 | gopkg.in/yaml.v3 v3.0.1 | |
| 28 | 27 | ) | |
| 29 | 28 | ||
| @@ -66,9 +65,7 @@ require ( | |||
| 66 | 65 | go.uber.org/multierr v1.11.0 // indirect | |
| 67 | 66 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 68 | 67 | golang.org/x/sync v0.20.0 // indirect | |
| 69 | - | golang.org/x/text v0.35.0 // indirect | |
| 68 | + | golang.org/x/text v0.36.0 // indirect | |
| 70 | 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | |
| 71 | 70 | modernc.org/sqlite v1.47.0 // indirect | |
| 72 | 71 | ) | |
| 73 | - | ||
| 74 | - | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | |
M go.sum +22 -33 generated file: collapsed
A internal/application/dto/clipboard.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // ClipboardSource identifies the originating engine for clipboard events. | |
| 4 | + | type ClipboardSource string | |
| 5 | + | ||
| 6 | + | const ( | |
| 7 | + | // ClipboardSourceCEF marks events from the CEF engine. | |
| 8 | + | ClipboardSourceCEF ClipboardSource = "cef" | |
| 9 | + | // ClipboardSourceWebKit marks events from the WebKit engine. | |
| 10 | + | ClipboardSourceWebKit ClipboardSource = "webkit" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // SelectionClipboardInput carries auto-copy selection updates. | |
| 14 | + | type SelectionClipboardInput struct { | |
| 15 | + | Text string | |
| 16 | + | SourceEngine ClipboardSource | |
| 17 | + | ViewID uint64 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // ExplicitClipboardInput carries explicit copy requests. | |
| 21 | + | type ExplicitClipboardInput struct { | |
| 22 | + | Text string | |
| 23 | + | SourceEngine ClipboardSource | |
| 24 | + | ViewID uint64 | |
| 25 | + | Action string | |
| 26 | + | NativeHandled bool | |
| 27 | + | } | |
A internal/application/port/browser_launch.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // BrowserWindowOpener opens a fresh browser window. | |
| 9 | + | type BrowserWindowOpener interface { | |
| 10 | + | OpenFreshWindow(ctx context.Context, url string) error | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // BrowserLaunchRelay delivers fresh-window launch requests. | |
| 14 | + | type BrowserLaunchRelay interface { | |
| 15 | + | // DeliverOpenFreshWindow attempts to deliver a request to open a fresh window. | |
| 16 | + | // The bool reports whether the relay accepted the request; false means the | |
| 17 | + | // caller should treat it as undelivered and try another path. | |
| 18 | + | DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) | |
| 19 | + | Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches. | |
| 23 | + | type AlreadyRunningAppRelaunchHandlerSetter interface { | |
| 24 | + | SetAlreadyRunningAppRelaunchHandler(func(string)) | |
| 25 | + | } | |
M internal/application/port/clipboard.go +8 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package port | |
| 2 | 2 | ||
| 3 | - | import "context" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // Clipboard defines the port interface for clipboard operations. | |
| 6 | 10 | // This abstracts platform-specific clipboard implementations (GTK, etc.). | |
| @@ -8,6 +12,9 @@ type Clipboard interface { | |||
| 8 | 12 | // WriteText copies text to the clipboard. | |
| 9 | 13 | WriteText(ctx context.Context, text string) error | |
| 10 | 14 | ||
| 15 | + | // WriteImage copies encoded image bytes to the clipboard. | |
| 16 | + | WriteImage(ctx context.Context, image entity.ImageData) error | |
| 17 | + | ||
| 11 | 18 | // ReadText reads text from the clipboard. | |
| 12 | 19 | // Returns empty string if clipboard is empty or contains non-text data. | |
| 13 | 20 | ReadText(ctx context.Context) (string, error) | |
A internal/application/port/clipboard_events.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ClipboardTextOrchestrator coordinates shared clipboard behavior. | |
| 10 | + | type ClipboardTextOrchestrator interface { | |
| 11 | + | HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error | |
| 12 | + | HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error | |
| 13 | + | } | |
M internal/application/port/config_transformer.go +4 -0
| @@ -6,4 +6,8 @@ type ConfigTransformer interface { | |||
| 6 | 6 | // to new ActionBinding format (map with keys and desc). | |
| 7 | 7 | // Modifies the rawConfig map in place. | |
| 8 | 8 | TransformLegacyActions(rawConfig map[string]any) | |
| 9 | + | ||
| 10 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 11 | + | // Modifies the rawConfig map in place. | |
| 12 | + | TransformLegacyEngineConfig(rawConfig map[string]any) | |
| 9 | 13 | } | |
A internal/application/port/context_menu.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // MenuAction identifies a normalized context menu action. | |
| 6 | + | type MenuAction string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | MenuActionBack MenuAction = "back" | |
| 10 | + | MenuActionForward MenuAction = "forward" | |
| 11 | + | MenuActionReload MenuAction = "reload" | |
| 12 | + | MenuActionOpenLinkNewTab MenuAction = "open_link_new_tab" | |
| 13 | + | MenuActionCopyLink MenuAction = "copy_link" | |
| 14 | + | MenuActionCopyImage MenuAction = "copy_image" | |
| 15 | + | MenuActionSaveImage MenuAction = "save_image" | |
| 16 | + | MenuActionInspectElement MenuAction = "inspect_element" | |
| 17 | + | MenuActionCopySelection MenuAction = "copy_selection" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // MenuContext captures the state needed to build and execute a context menu. | |
| 21 | + | type MenuContext struct { | |
| 22 | + | PageURI string | |
| 23 | + | LinkURI string | |
| 24 | + | ImageURI string | |
| 25 | + | SelectionText string | |
| 26 | + | HasSelection bool | |
| 27 | + | IsEditable bool | |
| 28 | + | CanGoBack bool | |
| 29 | + | CanGoForward bool | |
| 30 | + | X int | |
| 31 | + | Y int | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // MenuItem is a normalized context menu entry. | |
| 35 | + | type MenuItem struct { | |
| 36 | + | Action MenuAction | |
| 37 | + | Label string | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // ContextMenuBuilder builds normalized menu items from menu state. | |
| 41 | + | type ContextMenuBuilder interface { | |
| 42 | + | Build(ctx context.Context, menuContext MenuContext) []MenuItem | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ContextMenuActionExecutor executes normalized context menu actions. | |
| 46 | + | type ContextMenuActionExecutor interface { | |
| 47 | + | ExecuteMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ContextMenuActionExecutorFactory creates context menu action executors. | |
| 51 | + | type ContextMenuActionExecutorFactory interface { | |
| 52 | + | NewContextMenuActionExecutor( | |
| 53 | + | clipboard Clipboard, | |
| 54 | + | resolver ImageDataResolver, | |
| 55 | + | saver ResolvedImageSaver, | |
| 56 | + | delegator MenuActionDelegator, | |
| 57 | + | ) ContextMenuActionExecutor | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // MenuActionDelegator handles actions that are not shared application concerns. | |
| 61 | + | type MenuActionDelegator interface { | |
| 62 | + | DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | |
| 63 | + | } | |
M internal/application/port/download.go +9 -4
| @@ -8,6 +8,8 @@ type DownloadEventType int | |||
| 8 | 8 | const ( | |
| 9 | 9 | // DownloadEventStarted indicates a download has begun. | |
| 10 | 10 | DownloadEventStarted DownloadEventType = iota | |
| 11 | + | // DownloadEventProgress indicates download progress has advanced. | |
| 12 | + | DownloadEventProgress | |
| 11 | 13 | // DownloadEventFinished indicates a download completed successfully. | |
| 12 | 14 | DownloadEventFinished | |
| 13 | 15 | // DownloadEventFailed indicates a download failed. | |
| @@ -16,10 +18,13 @@ const ( | |||
| 16 | 18 | ||
| 17 | 19 | // DownloadEvent contains information about a download event. | |
| 18 | 20 | type DownloadEvent struct { | |
| 19 | - | Type DownloadEventType | |
| 20 | - | Filename string | |
| 21 | - | Destination string | |
| 22 | - | Error error // Set when Type is DownloadEventFailed | |
| 21 | + | Type DownloadEventType | |
| 22 | + | Filename string | |
| 23 | + | Destination string | |
| 24 | + | Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0. | |
| 25 | + | BytesReceived int64 // Best-effort received byte count for progress updates. | |
| 26 | + | BytesTotal int64 // Best-effort total byte count for progress updates when known. | |
| 27 | + | Error error // Set when Type is DownloadEventFailed | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | 30 | // DownloadEventHandler receives download event notifications. | |
M internal/application/port/engine.go +6 -5
| @@ -193,11 +193,12 @@ type WebUIMessage struct { | |||
| 193 | 193 | ||
| 194 | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | |
| 195 | 195 | type HandlerDependencies struct { | |
| 196 | - | HistoryUC HomepageHistory | |
| 197 | - | FavoritesUC HomepageFavorites | |
| 198 | - | Clipboard Clipboard | |
| 199 | - | AutoCopyConfig AutoCopyConfig | |
| 200 | - | OnClipboardCopied func(textLen int) | |
| 196 | + | HistoryUC HomepageHistory | |
| 197 | + | FavoritesUC HomepageFavorites | |
| 198 | + | Clipboard Clipboard | |
| 199 | + | AutoCopyConfig AutoCopyConfig | |
| 200 | + | ClipboardTextOrchestrator ClipboardTextOrchestrator | |
| 201 | + | OnClipboardCopied func(textLen int) | |
| 201 | 202 | HandlerDeps | |
| 202 | 203 | } | |
| 203 | 204 | ||
A internal/application/port/image_data.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ImageDataResolver resolves image bytes from a URI. | |
| 10 | + | type ImageDataResolver interface { | |
| 11 | + | ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // ResolvedImageSaver persists already resolved image bytes. | |
| 15 | + | type ResolvedImageSaver interface { | |
| 16 | + | SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error | |
| 17 | + | } | |
M internal/application/port/mocks/mocks.go +1481 -159
| @@ -8,8 +8,10 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "database/sql" | |
| 10 | 10 | "encoding/json" | |
| 11 | + | "io" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 13 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 16 | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 17 | "github.com/rs/zerolog" | |
| @@ -172,6 +174,251 @@ func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, se | |||
| 172 | 174 | return _c | |
| 173 | 175 | } | |
| 174 | 176 | ||
| 177 | + | // NewMockBrowserWindowOpener creates a new instance of MockBrowserWindowOpener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 178 | + | // The first argument is typically a *testing.T value. | |
| 179 | + | func NewMockBrowserWindowOpener(t interface { | |
| 180 | + | mock.TestingT | |
| 181 | + | Cleanup(func()) | |
| 182 | + | }) *MockBrowserWindowOpener { | |
| 183 | + | mock := &MockBrowserWindowOpener{} | |
| 184 | + | mock.Mock.Test(t) | |
| 185 | + | ||
| 186 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 187 | + | ||
| 188 | + | return mock | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // MockBrowserWindowOpener is an autogenerated mock type for the BrowserWindowOpener type | |
| 192 | + | type MockBrowserWindowOpener struct { | |
| 193 | + | mock.Mock | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | type MockBrowserWindowOpener_Expecter struct { | |
| 197 | + | mock *mock.Mock | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter { | |
| 201 | + | return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener | |
| 205 | + | func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error { | |
| 206 | + | ret := _mock.Called(ctx, url) | |
| 207 | + | ||
| 208 | + | if len(ret) == 0 { | |
| 209 | + | panic("no return value specified for OpenFreshWindow") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | var r0 error | |
| 213 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 214 | + | r0 = returnFunc(ctx, url) | |
| 215 | + | } else { | |
| 216 | + | r0 = ret.Error(0) | |
| 217 | + | } | |
| 218 | + | return r0 | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // MockBrowserWindowOpener_OpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenFreshWindow' | |
| 222 | + | type MockBrowserWindowOpener_OpenFreshWindow_Call struct { | |
| 223 | + | *mock.Call | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // OpenFreshWindow is a helper method to define mock.On call | |
| 227 | + | // - ctx context.Context | |
| 228 | + | // - url string | |
| 229 | + | func (_e *MockBrowserWindowOpener_Expecter) OpenFreshWindow(ctx interface{}, url interface{}) *MockBrowserWindowOpener_OpenFreshWindow_Call { | |
| 230 | + | return &MockBrowserWindowOpener_OpenFreshWindow_Call{Call: _e.mock.On("OpenFreshWindow", ctx, url)} | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenFreshWindow_Call { | |
| 234 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 235 | + | var arg0 context.Context | |
| 236 | + | if args[0] != nil { | |
| 237 | + | arg0 = args[0].(context.Context) | |
| 238 | + | } | |
| 239 | + | var arg1 string | |
| 240 | + | if args[1] != nil { | |
| 241 | + | arg1 = args[1].(string) | |
| 242 | + | } | |
| 243 | + | run( | |
| 244 | + | arg0, | |
| 245 | + | arg1, | |
| 246 | + | ) | |
| 247 | + | }) | |
| 248 | + | return _c | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Return(err error) *MockBrowserWindowOpener_OpenFreshWindow_Call { | |
| 252 | + | _c.Call.Return(err) | |
| 253 | + | return _c | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenFreshWindow_Call { | |
| 257 | + | _c.Call.Return(run) | |
| 258 | + | return _c | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // NewMockBrowserLaunchRelay creates a new instance of MockBrowserLaunchRelay. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 262 | + | // The first argument is typically a *testing.T value. | |
| 263 | + | func NewMockBrowserLaunchRelay(t interface { | |
| 264 | + | mock.TestingT | |
| 265 | + | Cleanup(func()) | |
| 266 | + | }) *MockBrowserLaunchRelay { | |
| 267 | + | mock := &MockBrowserLaunchRelay{} | |
| 268 | + | mock.Mock.Test(t) | |
| 269 | + | ||
| 270 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 271 | + | ||
| 272 | + | return mock | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // MockBrowserLaunchRelay is an autogenerated mock type for the BrowserLaunchRelay type | |
| 276 | + | type MockBrowserLaunchRelay struct { | |
| 277 | + | mock.Mock | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | type MockBrowserLaunchRelay_Expecter struct { | |
| 281 | + | mock *mock.Mock | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter { | |
| 285 | + | return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock} | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay | |
| 289 | + | func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | |
| 290 | + | ret := _mock.Called(ctx, url) | |
| 291 | + | ||
| 292 | + | if len(ret) == 0 { | |
| 293 | + | panic("no return value specified for DeliverOpenFreshWindow") | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | var r0 bool | |
| 297 | + | var r1 error | |
| 298 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { | |
| 299 | + | return returnFunc(ctx, url) | |
| 300 | + | } | |
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |
| 302 | + | r0 = returnFunc(ctx, url) | |
| 303 | + | } else { | |
| 304 | + | r0 = ret.Get(0).(bool) | |
| 305 | + | } | |
| 306 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 307 | + | r1 = returnFunc(ctx, url) | |
| 308 | + | } else { | |
| 309 | + | r1 = ret.Error(1) | |
| 310 | + | } | |
| 311 | + | return r0, r1 | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenFreshWindow' | |
| 315 | + | type MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call struct { | |
| 316 | + | *mock.Call | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // DeliverOpenFreshWindow is a helper method to define mock.On call | |
| 320 | + | // - ctx context.Context | |
| 321 | + | // - url string | |
| 322 | + | func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenFreshWindow(ctx interface{}, url interface{}) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call { | |
| 323 | + | return &MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call{Call: _e.mock.On("DeliverOpenFreshWindow", ctx, url)} | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call { | |
| 327 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 328 | + | var arg0 context.Context | |
| 329 | + | if args[0] != nil { | |
| 330 | + | arg0 = args[0].(context.Context) | |
| 331 | + | } | |
| 332 | + | var arg1 string | |
| 333 | + | if args[1] != nil { | |
| 334 | + | arg1 = args[1].(string) | |
| 335 | + | } | |
| 336 | + | run( | |
| 337 | + | arg0, | |
| 338 | + | arg1, | |
| 339 | + | ) | |
| 340 | + | }) | |
| 341 | + | return _c | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call { | |
| 345 | + | _c.Call.Return(b, err) | |
| 346 | + | return _c | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call { | |
| 350 | + | _c.Call.Return(run) | |
| 351 | + | return _c | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // Listen provides a mock function for the type MockBrowserLaunchRelay | |
| 355 | + | func (_mock *MockBrowserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | |
| 356 | + | ret := _mock.Called(ctx, opener) | |
| 357 | + | ||
| 358 | + | if len(ret) == 0 { | |
| 359 | + | panic("no return value specified for Listen") | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | var r0 io.Closer | |
| 363 | + | var r1 error | |
| 364 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.BrowserWindowOpener) (io.Closer, error)); ok { | |
| 365 | + | return returnFunc(ctx, opener) | |
| 366 | + | } | |
| 367 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.BrowserWindowOpener) io.Closer); ok { | |
| 368 | + | r0 = returnFunc(ctx, opener) | |
| 369 | + | } else { | |
| 370 | + | if ret.Get(0) != nil { | |
| 371 | + | r0 = ret.Get(0).(io.Closer) | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, port.BrowserWindowOpener) error); ok { | |
| 375 | + | r1 = returnFunc(ctx, opener) | |
| 376 | + | } else { | |
| 377 | + | r1 = ret.Error(1) | |
| 378 | + | } | |
| 379 | + | return r0, r1 | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // MockBrowserLaunchRelay_Listen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Listen' | |
| 383 | + | type MockBrowserLaunchRelay_Listen_Call struct { | |
| 384 | + | *mock.Call | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // Listen is a helper method to define mock.On call | |
| 388 | + | // - ctx context.Context | |
| 389 | + | // - opener port.BrowserWindowOpener | |
| 390 | + | func (_e *MockBrowserLaunchRelay_Expecter) Listen(ctx interface{}, opener interface{}) *MockBrowserLaunchRelay_Listen_Call { | |
| 391 | + | return &MockBrowserLaunchRelay_Listen_Call{Call: _e.mock.On("Listen", ctx, opener)} | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func (_c *MockBrowserLaunchRelay_Listen_Call) Run(run func(ctx context.Context, opener port.BrowserWindowOpener)) *MockBrowserLaunchRelay_Listen_Call { | |
| 395 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 396 | + | var arg0 context.Context | |
| 397 | + | if args[0] != nil { | |
| 398 | + | arg0 = args[0].(context.Context) | |
| 399 | + | } | |
| 400 | + | var arg1 port.BrowserWindowOpener | |
| 401 | + | if args[1] != nil { | |
| 402 | + | arg1 = args[1].(port.BrowserWindowOpener) | |
| 403 | + | } | |
| 404 | + | run( | |
| 405 | + | arg0, | |
| 406 | + | arg1, | |
| 407 | + | ) | |
| 408 | + | }) | |
| 409 | + | return _c | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (_c *MockBrowserLaunchRelay_Listen_Call) Return(closer io.Closer, err error) *MockBrowserLaunchRelay_Listen_Call { | |
| 413 | + | _c.Call.Return(closer, err) | |
| 414 | + | return _c | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (_c *MockBrowserLaunchRelay_Listen_Call) RunAndReturn(run func(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error)) *MockBrowserLaunchRelay_Listen_Call { | |
| 418 | + | _c.Call.Return(run) | |
| 419 | + | return _c | |
| 420 | + | } | |
| 421 | + | ||
| 175 | 422 | // NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 176 | 423 | // The first argument is typically a *testing.T value. | |
| 177 | 424 | func NewMockClipboard(t interface { | |
| @@ -370,6 +617,63 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context | |||
| 370 | 617 | return _c | |
| 371 | 618 | } | |
| 372 | 619 | ||
| 620 | + | // WriteImage provides a mock function for the type MockClipboard | |
| 621 | + | func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 622 | + | ret := _mock.Called(ctx, image) | |
| 623 | + | ||
| 624 | + | if len(ret) == 0 { | |
| 625 | + | panic("no return value specified for WriteImage") | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | var r0 error | |
| 629 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok { | |
| 630 | + | r0 = returnFunc(ctx, image) | |
| 631 | + | } else { | |
| 632 | + | r0 = ret.Error(0) | |
| 633 | + | } | |
| 634 | + | return r0 | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage' | |
| 638 | + | type MockClipboard_WriteImage_Call struct { | |
| 639 | + | *mock.Call | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | // WriteImage is a helper method to define mock.On call | |
| 643 | + | // - ctx context.Context | |
| 644 | + | // - image entity.ImageData | |
| 645 | + | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | |
| 646 | + | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call { | |
| 650 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 651 | + | var arg0 context.Context | |
| 652 | + | if args[0] != nil { | |
| 653 | + | arg0 = args[0].(context.Context) | |
| 654 | + | } | |
| 655 | + | var arg1 entity.ImageData | |
| 656 | + | if args[1] != nil { | |
| 657 | + | arg1 = args[1].(entity.ImageData) | |
| 658 | + | } | |
| 659 | + | run( | |
| 660 | + | arg0, | |
| 661 | + | arg1, | |
| 662 | + | ) | |
| 663 | + | }) | |
| 664 | + | return _c | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call { | |
| 668 | + | _c.Call.Return(err) | |
| 669 | + | return _c | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call { | |
| 673 | + | _c.Call.Return(run) | |
| 674 | + | return _c | |
| 675 | + | } | |
| 676 | + | ||
| 373 | 677 | // WriteText provides a mock function for the type MockClipboard | |
| 374 | 678 | func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error { | |
| 375 | 679 | ret := _mock.Called(ctx, text) | |
| @@ -427,13 +731,13 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||
| 427 | 731 | return _c | |
| 428 | 732 | } | |
| 429 | 733 | ||
| 430 | - | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 734 | + | // NewMockClipboardTextOrchestrator creates a new instance of MockClipboardTextOrchestrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 431 | 735 | // The first argument is typically a *testing.T value. | |
| 432 | - | func NewMockColorSchemeDetector(t interface { | |
| 736 | + | func NewMockClipboardTextOrchestrator(t interface { | |
| 433 | 737 | mock.TestingT | |
| 434 | 738 | Cleanup(func()) | |
| 435 | - | }) *MockColorSchemeDetector { | |
| 436 | - | mock := &MockColorSchemeDetector{} | |
| 739 | + | }) *MockClipboardTextOrchestrator { | |
| 740 | + | mock := &MockClipboardTextOrchestrator{} | |
| 437 | 741 | mock.Mock.Test(t) | |
| 438 | 742 | ||
| 439 | 743 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -441,59 +745,200 @@ func NewMockColorSchemeDetector(t interface { | |||
| 441 | 745 | return mock | |
| 442 | 746 | } | |
| 443 | 747 | ||
| 444 | - | // MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type | |
| 445 | - | type MockColorSchemeDetector struct { | |
| 748 | + | // MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type | |
| 749 | + | type MockClipboardTextOrchestrator struct { | |
| 446 | 750 | mock.Mock | |
| 447 | 751 | } | |
| 448 | 752 | ||
| 449 | - | type MockColorSchemeDetector_Expecter struct { | |
| 753 | + | type MockClipboardTextOrchestrator_Expecter struct { | |
| 450 | 754 | mock *mock.Mock | |
| 451 | 755 | } | |
| 452 | 756 | ||
| 453 | - | func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter { | |
| 454 | - | return &MockColorSchemeDetector_Expecter{mock: &_m.Mock} | |
| 757 | + | func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter { | |
| 758 | + | return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock} | |
| 455 | 759 | } | |
| 456 | 760 | ||
| 457 | - | // Available provides a mock function for the type MockColorSchemeDetector | |
| 458 | - | func (_mock *MockColorSchemeDetector) Available() bool { | |
| 459 | - | ret := _mock.Called() | |
| 761 | + | // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator | |
| 762 | + | func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error { | |
| 763 | + | ret := _mock.Called(ctx, input) | |
| 460 | 764 | ||
| 461 | 765 | if len(ret) == 0 { | |
| 462 | - | panic("no return value specified for Available") | |
| 766 | + | panic("no return value specified for HandleExplicitCopy") | |
| 463 | 767 | } | |
| 464 | 768 | ||
| 465 | - | var r0 bool | |
| 466 | - | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 467 | - | r0 = returnFunc() | |
| 769 | + | var r0 error | |
| 770 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.ExplicitClipboardInput) error); ok { | |
| 771 | + | r0 = returnFunc(ctx, input) | |
| 468 | 772 | } else { | |
| 469 | - | r0 = ret.Get(0).(bool) | |
| 773 | + | r0 = ret.Error(0) | |
| 470 | 774 | } | |
| 471 | 775 | return r0 | |
| 472 | 776 | } | |
| 473 | 777 | ||
| 474 | - | // MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available' | |
| 475 | - | type MockColorSchemeDetector_Available_Call struct { | |
| 778 | + | // MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy' | |
| 779 | + | type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct { | |
| 476 | 780 | *mock.Call | |
| 477 | 781 | } | |
| 478 | 782 | ||
| 479 | - | // Available is a helper method to define mock.On call | |
| 480 | - | func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call { | |
| 481 | - | return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")} | |
| 783 | + | // HandleExplicitCopy is a helper method to define mock.On call | |
| 784 | + | // - ctx context.Context | |
| 785 | + | // - input dto.ExplicitClipboardInput | |
| 786 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 787 | + | return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)} | |
| 482 | 788 | } | |
| 483 | 789 | ||
| 484 | - | func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call { | |
| 790 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input dto.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 485 | 791 | _c.Call.Run(func(args mock.Arguments) { | |
| 486 | - | run() | |
| 792 | + | var arg0 context.Context | |
| 793 | + | if args[0] != nil { | |
| 794 | + | arg0 = args[0].(context.Context) | |
| 795 | + | } | |
| 796 | + | var arg1 dto.ExplicitClipboardInput | |
| 797 | + | if args[1] != nil { | |
| 798 | + | arg1 = args[1].(dto.ExplicitClipboardInput) | |
| 799 | + | } | |
| 800 | + | run( | |
| 801 | + | arg0, | |
| 802 | + | arg1, | |
| 803 | + | ) | |
| 487 | 804 | }) | |
| 488 | 805 | return _c | |
| 489 | 806 | } | |
| 490 | 807 | ||
| 491 | - | func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call { | |
| 492 | - | _c.Call.Return(b) | |
| 808 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 809 | + | _c.Call.Return(err) | |
| 493 | 810 | return _c | |
| 494 | 811 | } | |
| 495 | 812 | ||
| 496 | - | func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call { | |
| 813 | + | func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input dto.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call { | |
| 814 | + | _c.Call.Return(run) | |
| 815 | + | return _c | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator | |
| 819 | + | func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error { | |
| 820 | + | ret := _mock.Called(ctx, input) | |
| 821 | + | ||
| 822 | + | if len(ret) == 0 { | |
| 823 | + | panic("no return value specified for HandleSelectionUpdate") | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | var r0 error | |
| 827 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.SelectionClipboardInput) error); ok { | |
| 828 | + | r0 = returnFunc(ctx, input) | |
| 829 | + | } else { | |
| 830 | + | r0 = ret.Error(0) | |
| 831 | + | } | |
| 832 | + | return r0 | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | // MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate' | |
| 836 | + | type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct { | |
| 837 | + | *mock.Call | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | // HandleSelectionUpdate is a helper method to define mock.On call | |
| 841 | + | // - ctx context.Context | |
| 842 | + | // - input dto.SelectionClipboardInput | |
| 843 | + | func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 844 | + | return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)} | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input dto.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 848 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 849 | + | var arg0 context.Context | |
| 850 | + | if args[0] != nil { | |
| 851 | + | arg0 = args[0].(context.Context) | |
| 852 | + | } | |
| 853 | + | var arg1 dto.SelectionClipboardInput | |
| 854 | + | if args[1] != nil { | |
| 855 | + | arg1 = args[1].(dto.SelectionClipboardInput) | |
| 856 | + | } | |
| 857 | + | run( | |
| 858 | + | arg0, | |
| 859 | + | arg1, | |
| 860 | + | ) | |
| 861 | + | }) | |
| 862 | + | return _c | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 866 | + | _c.Call.Return(err) | |
| 867 | + | return _c | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input dto.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call { | |
| 871 | + | _c.Call.Return(run) | |
| 872 | + | return _c | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 876 | + | // The first argument is typically a *testing.T value. | |
| 877 | + | func NewMockColorSchemeDetector(t interface { | |
| 878 | + | mock.TestingT | |
| 879 | + | Cleanup(func()) | |
| 880 | + | }) *MockColorSchemeDetector { | |
| 881 | + | mock := &MockColorSchemeDetector{} | |
| 882 | + | mock.Mock.Test(t) | |
| 883 | + | ||
| 884 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 885 | + | ||
| 886 | + | return mock | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | // MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type | |
| 890 | + | type MockColorSchemeDetector struct { | |
| 891 | + | mock.Mock | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | type MockColorSchemeDetector_Expecter struct { | |
| 895 | + | mock *mock.Mock | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter { | |
| 899 | + | return &MockColorSchemeDetector_Expecter{mock: &_m.Mock} | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | // Available provides a mock function for the type MockColorSchemeDetector | |
| 903 | + | func (_mock *MockColorSchemeDetector) Available() bool { | |
| 904 | + | ret := _mock.Called() | |
| 905 | + | ||
| 906 | + | if len(ret) == 0 { | |
| 907 | + | panic("no return value specified for Available") | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | var r0 bool | |
| 911 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 912 | + | r0 = returnFunc() | |
| 913 | + | } else { | |
| 914 | + | r0 = ret.Get(0).(bool) | |
| 915 | + | } | |
| 916 | + | return r0 | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | // MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available' | |
| 920 | + | type MockColorSchemeDetector_Available_Call struct { | |
| 921 | + | *mock.Call | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | // Available is a helper method to define mock.On call | |
| 925 | + | func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call { | |
| 926 | + | return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")} | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call { | |
| 930 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 931 | + | run() | |
| 932 | + | }) | |
| 933 | + | return _c | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call { | |
| 937 | + | _c.Call.Return(b) | |
| 938 | + | return _c | |
| 939 | + | } | |
| 940 | + | ||
| 941 | + | func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call { | |
| 497 | 942 | _c.Call.Return(run) | |
| 498 | 943 | return _c | |
| 499 | 944 | } | |
| @@ -1387,47 +1832,371 @@ func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string] | |||
| 1387 | 1832 | return | |
| 1388 | 1833 | } | |
| 1389 | 1834 | ||
| 1390 | - | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |
| 1391 | - | type MockConfigTransformer_TransformLegacyActions_Call struct { | |
| 1835 | + | // MockConfigTransformer_TransformLegacyActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyActions' | |
| 1836 | + | type MockConfigTransformer_TransformLegacyActions_Call struct { | |
| 1837 | + | *mock.Call | |
| 1838 | + | } | |
| 1839 | + | ||
| 1840 | + | // TransformLegacyActions is a helper method to define mock.On call | |
| 1841 | + | // - rawConfig map[string]any | |
| 1842 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1843 | + | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |
| 1844 | + | } | |
| 1845 | + | ||
| 1846 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1847 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1848 | + | var arg0 map[string]any | |
| 1849 | + | if args[0] != nil { | |
| 1850 | + | arg0 = args[0].(map[string]any) | |
| 1851 | + | } | |
| 1852 | + | run( | |
| 1853 | + | arg0, | |
| 1854 | + | ) | |
| 1855 | + | }) | |
| 1856 | + | return _c | |
| 1857 | + | } | |
| 1858 | + | ||
| 1859 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1860 | + | _c.Call.Return() | |
| 1861 | + | return _c | |
| 1862 | + | } | |
| 1863 | + | ||
| 1864 | + | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1865 | + | _c.Run(run) | |
| 1866 | + | return _c | |
| 1867 | + | } | |
| 1868 | + | ||
| 1869 | + | // TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer | |
| 1870 | + | func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 1871 | + | _mock.Called(rawConfig) | |
| 1872 | + | return | |
| 1873 | + | } | |
| 1874 | + | ||
| 1875 | + | // MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig' | |
| 1876 | + | type MockConfigTransformer_TransformLegacyEngineConfig_Call struct { | |
| 1877 | + | *mock.Call | |
| 1878 | + | } | |
| 1879 | + | ||
| 1880 | + | // TransformLegacyEngineConfig is a helper method to define mock.On call | |
| 1881 | + | // - rawConfig map[string]any | |
| 1882 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1883 | + | return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)} | |
| 1884 | + | } | |
| 1885 | + | ||
| 1886 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1887 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1888 | + | var arg0 map[string]any | |
| 1889 | + | if args[0] != nil { | |
| 1890 | + | arg0 = args[0].(map[string]any) | |
| 1891 | + | } | |
| 1892 | + | run( | |
| 1893 | + | arg0, | |
| 1894 | + | ) | |
| 1895 | + | }) | |
| 1896 | + | return _c | |
| 1897 | + | } | |
| 1898 | + | ||
| 1899 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1900 | + | _c.Call.Return() | |
| 1901 | + | return _c | |
| 1902 | + | } | |
| 1903 | + | ||
| 1904 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1905 | + | _c.Run(run) | |
| 1906 | + | return _c | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1910 | + | // The first argument is typically a *testing.T value. | |
| 1911 | + | func NewMockContentInjector(t interface { | |
| 1912 | + | mock.TestingT | |
| 1913 | + | Cleanup(func()) | |
| 1914 | + | }) *MockContentInjector { | |
| 1915 | + | mock := &MockContentInjector{} | |
| 1916 | + | mock.Mock.Test(t) | |
| 1917 | + | ||
| 1918 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1919 | + | ||
| 1920 | + | return mock | |
| 1921 | + | } | |
| 1922 | + | ||
| 1923 | + | // MockContentInjector is an autogenerated mock type for the ContentInjector type | |
| 1924 | + | type MockContentInjector struct { | |
| 1925 | + | mock.Mock | |
| 1926 | + | } | |
| 1927 | + | ||
| 1928 | + | type MockContentInjector_Expecter struct { | |
| 1929 | + | mock *mock.Mock | |
| 1930 | + | } | |
| 1931 | + | ||
| 1932 | + | func (_m *MockContentInjector) EXPECT() *MockContentInjector_Expecter { | |
| 1933 | + | return &MockContentInjector_Expecter{mock: &_m.Mock} | |
| 1934 | + | } | |
| 1935 | + | ||
| 1936 | + | // InjectFindHighlightCSS provides a mock function for the type MockContentInjector | |
| 1937 | + | func (_mock *MockContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error { | |
| 1938 | + | ret := _mock.Called(ctx, css) | |
| 1939 | + | ||
| 1940 | + | if len(ret) == 0 { | |
| 1941 | + | panic("no return value specified for InjectFindHighlightCSS") | |
| 1942 | + | } | |
| 1943 | + | ||
| 1944 | + | var r0 error | |
| 1945 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 1946 | + | r0 = returnFunc(ctx, css) | |
| 1947 | + | } else { | |
| 1948 | + | r0 = ret.Error(0) | |
| 1949 | + | } | |
| 1950 | + | return r0 | |
| 1951 | + | } | |
| 1952 | + | ||
| 1953 | + | // MockContentInjector_InjectFindHighlightCSS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InjectFindHighlightCSS' | |
| 1954 | + | type MockContentInjector_InjectFindHighlightCSS_Call struct { | |
| 1955 | + | *mock.Call | |
| 1956 | + | } | |
| 1957 | + | ||
| 1958 | + | // InjectFindHighlightCSS is a helper method to define mock.On call | |
| 1959 | + | // - ctx context.Context | |
| 1960 | + | // - css string | |
| 1961 | + | func (_e *MockContentInjector_Expecter) InjectFindHighlightCSS(ctx interface{}, css interface{}) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 1962 | + | return &MockContentInjector_InjectFindHighlightCSS_Call{Call: _e.mock.On("InjectFindHighlightCSS", ctx, css)} | |
| 1963 | + | } | |
| 1964 | + | ||
| 1965 | + | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) Run(run func(ctx context.Context, css string)) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 1966 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1967 | + | var arg0 context.Context | |
| 1968 | + | if args[0] != nil { | |
| 1969 | + | arg0 = args[0].(context.Context) | |
| 1970 | + | } | |
| 1971 | + | var arg1 string | |
| 1972 | + | if args[1] != nil { | |
| 1973 | + | arg1 = args[1].(string) | |
| 1974 | + | } | |
| 1975 | + | run( | |
| 1976 | + | arg0, | |
| 1977 | + | arg1, | |
| 1978 | + | ) | |
| 1979 | + | }) | |
| 1980 | + | return _c | |
| 1981 | + | } | |
| 1982 | + | ||
| 1983 | + | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) Return(err error) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 1984 | + | _c.Call.Return(err) | |
| 1985 | + | return _c | |
| 1986 | + | } | |
| 1987 | + | ||
| 1988 | + | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) RunAndReturn(run func(ctx context.Context, css string) error) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 1989 | + | _c.Call.Return(run) | |
| 1990 | + | return _c | |
| 1991 | + | } | |
| 1992 | + | ||
| 1993 | + | // InjectThemeCSS provides a mock function for the type MockContentInjector | |
| 1994 | + | func (_mock *MockContentInjector) InjectThemeCSS(ctx context.Context, css string) error { | |
| 1995 | + | ret := _mock.Called(ctx, css) | |
| 1996 | + | ||
| 1997 | + | if len(ret) == 0 { | |
| 1998 | + | panic("no return value specified for InjectThemeCSS") | |
| 1999 | + | } | |
| 2000 | + | ||
| 2001 | + | var r0 error | |
| 2002 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 2003 | + | r0 = returnFunc(ctx, css) | |
| 2004 | + | } else { | |
| 2005 | + | r0 = ret.Error(0) | |
| 2006 | + | } | |
| 2007 | + | return r0 | |
| 2008 | + | } | |
| 2009 | + | ||
| 2010 | + | // MockContentInjector_InjectThemeCSS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InjectThemeCSS' | |
| 2011 | + | type MockContentInjector_InjectThemeCSS_Call struct { | |
| 2012 | + | *mock.Call | |
| 2013 | + | } | |
| 2014 | + | ||
| 2015 | + | // InjectThemeCSS is a helper method to define mock.On call | |
| 2016 | + | // - ctx context.Context | |
| 2017 | + | // - css string | |
| 2018 | + | func (_e *MockContentInjector_Expecter) InjectThemeCSS(ctx interface{}, css interface{}) *MockContentInjector_InjectThemeCSS_Call { | |
| 2019 | + | return &MockContentInjector_InjectThemeCSS_Call{Call: _e.mock.On("InjectThemeCSS", ctx, css)} | |
| 2020 | + | } | |
| 2021 | + | ||
| 2022 | + | func (_c *MockContentInjector_InjectThemeCSS_Call) Run(run func(ctx context.Context, css string)) *MockContentInjector_InjectThemeCSS_Call { | |
| 2023 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2024 | + | var arg0 context.Context | |
| 2025 | + | if args[0] != nil { | |
| 2026 | + | arg0 = args[0].(context.Context) | |
| 2027 | + | } | |
| 2028 | + | var arg1 string | |
| 2029 | + | if args[1] != nil { | |
| 2030 | + | arg1 = args[1].(string) | |
| 2031 | + | } | |
| 2032 | + | run( | |
| 2033 | + | arg0, | |
| 2034 | + | arg1, | |
| 2035 | + | ) | |
| 2036 | + | }) | |
| 2037 | + | return _c | |
| 2038 | + | } | |
| 2039 | + | ||
| 2040 | + | func (_c *MockContentInjector_InjectThemeCSS_Call) Return(err error) *MockContentInjector_InjectThemeCSS_Call { | |
| 2041 | + | _c.Call.Return(err) | |
| 2042 | + | return _c | |
| 2043 | + | } | |
| 2044 | + | ||
| 2045 | + | func (_c *MockContentInjector_InjectThemeCSS_Call) RunAndReturn(run func(ctx context.Context, css string) error) *MockContentInjector_InjectThemeCSS_Call { | |
| 2046 | + | _c.Call.Return(run) | |
| 2047 | + | return _c | |
| 2048 | + | } | |
| 2049 | + | ||
| 2050 | + | // RefreshScripts provides a mock function for the type MockContentInjector | |
| 2051 | + | func (_mock *MockContentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error { | |
| 2052 | + | ret := _mock.Called(ctx, wv) | |
| 2053 | + | ||
| 2054 | + | if len(ret) == 0 { | |
| 2055 | + | panic("no return value specified for RefreshScripts") | |
| 2056 | + | } | |
| 2057 | + | ||
| 2058 | + | var r0 error | |
| 2059 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebView) error); ok { | |
| 2060 | + | r0 = returnFunc(ctx, wv) | |
| 2061 | + | } else { | |
| 2062 | + | r0 = ret.Error(0) | |
| 2063 | + | } | |
| 2064 | + | return r0 | |
| 2065 | + | } | |
| 2066 | + | ||
| 2067 | + | // MockContentInjector_RefreshScripts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshScripts' | |
| 2068 | + | type MockContentInjector_RefreshScripts_Call struct { | |
| 2069 | + | *mock.Call | |
| 2070 | + | } | |
| 2071 | + | ||
| 2072 | + | // RefreshScripts is a helper method to define mock.On call | |
| 2073 | + | // - ctx context.Context | |
| 2074 | + | // - wv port.WebView | |
| 2075 | + | func (_e *MockContentInjector_Expecter) RefreshScripts(ctx interface{}, wv interface{}) *MockContentInjector_RefreshScripts_Call { | |
| 2076 | + | return &MockContentInjector_RefreshScripts_Call{Call: _e.mock.On("RefreshScripts", ctx, wv)} | |
| 2077 | + | } | |
| 2078 | + | ||
| 2079 | + | func (_c *MockContentInjector_RefreshScripts_Call) Run(run func(ctx context.Context, wv port.WebView)) *MockContentInjector_RefreshScripts_Call { | |
| 2080 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2081 | + | var arg0 context.Context | |
| 2082 | + | if args[0] != nil { | |
| 2083 | + | arg0 = args[0].(context.Context) | |
| 2084 | + | } | |
| 2085 | + | var arg1 port.WebView | |
| 2086 | + | if args[1] != nil { | |
| 2087 | + | arg1 = args[1].(port.WebView) | |
| 2088 | + | } | |
| 2089 | + | run( | |
| 2090 | + | arg0, | |
| 2091 | + | arg1, | |
| 2092 | + | ) | |
| 2093 | + | }) | |
| 2094 | + | return _c | |
| 2095 | + | } | |
| 2096 | + | ||
| 2097 | + | func (_c *MockContentInjector_RefreshScripts_Call) Return(err error) *MockContentInjector_RefreshScripts_Call { | |
| 2098 | + | _c.Call.Return(err) | |
| 2099 | + | return _c | |
| 2100 | + | } | |
| 2101 | + | ||
| 2102 | + | func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx context.Context, wv port.WebView) error) *MockContentInjector_RefreshScripts_Call { | |
| 2103 | + | _c.Call.Return(run) | |
| 2104 | + | return _c | |
| 2105 | + | } | |
| 2106 | + | ||
| 2107 | + | // NewMockContextMenuBuilder creates a new instance of MockContextMenuBuilder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2108 | + | // The first argument is typically a *testing.T value. | |
| 2109 | + | func NewMockContextMenuBuilder(t interface { | |
| 2110 | + | mock.TestingT | |
| 2111 | + | Cleanup(func()) | |
| 2112 | + | }) *MockContextMenuBuilder { | |
| 2113 | + | mock := &MockContextMenuBuilder{} | |
| 2114 | + | mock.Mock.Test(t) | |
| 2115 | + | ||
| 2116 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2117 | + | ||
| 2118 | + | return mock | |
| 2119 | + | } | |
| 2120 | + | ||
| 2121 | + | // MockContextMenuBuilder is an autogenerated mock type for the ContextMenuBuilder type | |
| 2122 | + | type MockContextMenuBuilder struct { | |
| 2123 | + | mock.Mock | |
| 2124 | + | } | |
| 2125 | + | ||
| 2126 | + | type MockContextMenuBuilder_Expecter struct { | |
| 2127 | + | mock *mock.Mock | |
| 2128 | + | } | |
| 2129 | + | ||
| 2130 | + | func (_m *MockContextMenuBuilder) EXPECT() *MockContextMenuBuilder_Expecter { | |
| 2131 | + | return &MockContextMenuBuilder_Expecter{mock: &_m.Mock} | |
| 2132 | + | } | |
| 2133 | + | ||
| 2134 | + | // Build provides a mock function for the type MockContextMenuBuilder | |
| 2135 | + | func (_mock *MockContextMenuBuilder) Build(ctx context.Context, menuContext port.MenuContext) []port.MenuItem { | |
| 2136 | + | ret := _mock.Called(ctx, menuContext) | |
| 2137 | + | ||
| 2138 | + | if len(ret) == 0 { | |
| 2139 | + | panic("no return value specified for Build") | |
| 2140 | + | } | |
| 2141 | + | ||
| 2142 | + | var r0 []port.MenuItem | |
| 2143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuContext) []port.MenuItem); ok { | |
| 2144 | + | r0 = returnFunc(ctx, menuContext) | |
| 2145 | + | } else { | |
| 2146 | + | if ret.Get(0) != nil { | |
| 2147 | + | r0 = ret.Get(0).([]port.MenuItem) | |
| 2148 | + | } | |
| 2149 | + | } | |
| 2150 | + | return r0 | |
| 2151 | + | } | |
| 2152 | + | ||
| 2153 | + | // MockContextMenuBuilder_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' | |
| 2154 | + | type MockContextMenuBuilder_Build_Call struct { | |
| 1392 | 2155 | *mock.Call | |
| 1393 | 2156 | } | |
| 1394 | 2157 | ||
| 1395 | - | // TransformLegacyActions is a helper method to define mock.On call | |
| 1396 | - | // - rawConfig map[string]any | |
| 1397 | - | func (_e *MockConfigTransformer_Expecter) TransformLegacyActions(rawConfig interface{}) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1398 | - | return &MockConfigTransformer_TransformLegacyActions_Call{Call: _e.mock.On("TransformLegacyActions", rawConfig)} | |
| 2158 | + | // Build is a helper method to define mock.On call | |
| 2159 | + | // - ctx context.Context | |
| 2160 | + | // - menuContext port.MenuContext | |
| 2161 | + | func (_e *MockContextMenuBuilder_Expecter) Build(ctx interface{}, menuContext interface{}) *MockContextMenuBuilder_Build_Call { | |
| 2162 | + | return &MockContextMenuBuilder_Build_Call{Call: _e.mock.On("Build", ctx, menuContext)} | |
| 1399 | 2163 | } | |
| 1400 | 2164 | ||
| 1401 | - | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 2165 | + | func (_c *MockContextMenuBuilder_Build_Call) Run(run func(ctx context.Context, menuContext port.MenuContext)) *MockContextMenuBuilder_Build_Call { | |
| 1402 | 2166 | _c.Call.Run(func(args mock.Arguments) { | |
| 1403 | - | var arg0 map[string]any | |
| 2167 | + | var arg0 context.Context | |
| 1404 | 2168 | if args[0] != nil { | |
| 1405 | - | arg0 = args[0].(map[string]any) | |
| 2169 | + | arg0 = args[0].(context.Context) | |
| 2170 | + | } | |
| 2171 | + | var arg1 port.MenuContext | |
| 2172 | + | if args[1] != nil { | |
| 2173 | + | arg1 = args[1].(port.MenuContext) | |
| 1406 | 2174 | } | |
| 1407 | 2175 | run( | |
| 1408 | 2176 | arg0, | |
| 2177 | + | arg1, | |
| 1409 | 2178 | ) | |
| 1410 | 2179 | }) | |
| 1411 | 2180 | return _c | |
| 1412 | 2181 | } | |
| 1413 | 2182 | ||
| 1414 | - | func (_c *MockConfigTransformer_TransformLegacyActions_Call) Return() *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1415 | - | _c.Call.Return() | |
| 2183 | + | func (_c *MockContextMenuBuilder_Build_Call) Return(menuItems []port.MenuItem) *MockContextMenuBuilder_Build_Call { | |
| 2184 | + | _c.Call.Return(menuItems) | |
| 1416 | 2185 | return _c | |
| 1417 | 2186 | } | |
| 1418 | 2187 | ||
| 1419 | - | func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyActions_Call { | |
| 1420 | - | _c.Run(run) | |
| 2188 | + | func (_c *MockContextMenuBuilder_Build_Call) RunAndReturn(run func(ctx context.Context, menuContext port.MenuContext) []port.MenuItem) *MockContextMenuBuilder_Build_Call { | |
| 2189 | + | _c.Call.Return(run) | |
| 1421 | 2190 | return _c | |
| 1422 | 2191 | } | |
| 1423 | 2192 | ||
| 1424 | - | // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2193 | + | // NewMockContextMenuActionExecutor creates a new instance of MockContextMenuActionExecutor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1425 | 2194 | // The first argument is typically a *testing.T value. | |
| 1426 | - | func NewMockContentInjector(t interface { | |
| 2195 | + | func NewMockContextMenuActionExecutor(t interface { | |
| 1427 | 2196 | mock.TestingT | |
| 1428 | 2197 | Cleanup(func()) | |
| 1429 | - | }) *MockContentInjector { | |
| 1430 | - | mock := &MockContentInjector{} | |
| 2198 | + | }) *MockContextMenuActionExecutor { | |
| 2199 | + | mock := &MockContextMenuActionExecutor{} | |
| 1431 | 2200 | mock.Mock.Test(t) | |
| 1432 | 2201 | ||
| 1433 | 2202 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -1435,186 +2204,266 @@ func NewMockContentInjector(t interface { | |||
| 1435 | 2204 | return mock | |
| 1436 | 2205 | } | |
| 1437 | 2206 | ||
| 1438 | - | // MockContentInjector is an autogenerated mock type for the ContentInjector type | |
| 1439 | - | type MockContentInjector struct { | |
| 2207 | + | // MockContextMenuActionExecutor is an autogenerated mock type for the ContextMenuActionExecutor type | |
| 2208 | + | type MockContextMenuActionExecutor struct { | |
| 1440 | 2209 | mock.Mock | |
| 1441 | 2210 | } | |
| 1442 | 2211 | ||
| 1443 | - | type MockContentInjector_Expecter struct { | |
| 2212 | + | type MockContextMenuActionExecutor_Expecter struct { | |
| 1444 | 2213 | mock *mock.Mock | |
| 1445 | 2214 | } | |
| 1446 | 2215 | ||
| 1447 | - | func (_m *MockContentInjector) EXPECT() *MockContentInjector_Expecter { | |
| 1448 | - | return &MockContentInjector_Expecter{mock: &_m.Mock} | |
| 2216 | + | func (_m *MockContextMenuActionExecutor) EXPECT() *MockContextMenuActionExecutor_Expecter { | |
| 2217 | + | return &MockContextMenuActionExecutor_Expecter{mock: &_m.Mock} | |
| 1449 | 2218 | } | |
| 1450 | 2219 | ||
| 1451 | - | // InjectFindHighlightCSS provides a mock function for the type MockContentInjector | |
| 1452 | - | func (_mock *MockContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error { | |
| 1453 | - | ret := _mock.Called(ctx, css) | |
| 2220 | + | // ExecuteMenuAction provides a mock function for the type MockContextMenuActionExecutor | |
| 2221 | + | func (_mock *MockContextMenuActionExecutor) ExecuteMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 2222 | + | ret := _mock.Called(ctx, action, menuContext) | |
| 1454 | 2223 | ||
| 1455 | 2224 | if len(ret) == 0 { | |
| 1456 | - | panic("no return value specified for InjectFindHighlightCSS") | |
| 2225 | + | panic("no return value specified for ExecuteMenuAction") | |
| 1457 | 2226 | } | |
| 1458 | 2227 | ||
| 1459 | 2228 | var r0 error | |
| 1460 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 1461 | - | r0 = returnFunc(ctx, css) | |
| 2229 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok { | |
| 2230 | + | r0 = returnFunc(ctx, action, menuContext) | |
| 1462 | 2231 | } else { | |
| 1463 | 2232 | r0 = ret.Error(0) | |
| 1464 | 2233 | } | |
| 1465 | 2234 | return r0 | |
| 1466 | 2235 | } | |
| 1467 | 2236 | ||
| 1468 | - | // MockContentInjector_InjectFindHighlightCSS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InjectFindHighlightCSS' | |
| 1469 | - | type MockContentInjector_InjectFindHighlightCSS_Call struct { | |
| 2237 | + | // MockContextMenuActionExecutor_ExecuteMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecuteMenuAction' | |
| 2238 | + | type MockContextMenuActionExecutor_ExecuteMenuAction_Call struct { | |
| 1470 | 2239 | *mock.Call | |
| 1471 | 2240 | } | |
| 1472 | 2241 | ||
| 1473 | - | // InjectFindHighlightCSS is a helper method to define mock.On call | |
| 2242 | + | // ExecuteMenuAction is a helper method to define mock.On call | |
| 1474 | 2243 | // - ctx context.Context | |
| 1475 | - | // - css string | |
| 1476 | - | func (_e *MockContentInjector_Expecter) InjectFindHighlightCSS(ctx interface{}, css interface{}) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 1477 | - | return &MockContentInjector_InjectFindHighlightCSS_Call{Call: _e.mock.On("InjectFindHighlightCSS", ctx, css)} | |
| 2244 | + | // - action port.MenuAction | |
| 2245 | + | // - menuContext port.MenuContext | |
| 2246 | + | func (_e *MockContextMenuActionExecutor_Expecter) ExecuteMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockContextMenuActionExecutor_ExecuteMenuAction_Call { | |
| 2247 | + | return &MockContextMenuActionExecutor_ExecuteMenuAction_Call{Call: _e.mock.On("ExecuteMenuAction", ctx, action, menuContext)} | |
| 1478 | 2248 | } | |
| 1479 | 2249 | ||
| 1480 | - | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) Run(run func(ctx context.Context, css string)) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 2250 | + | func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockContextMenuActionExecutor_ExecuteMenuAction_Call { | |
| 1481 | 2251 | _c.Call.Run(func(args mock.Arguments) { | |
| 1482 | 2252 | var arg0 context.Context | |
| 1483 | 2253 | if args[0] != nil { | |
| 1484 | 2254 | arg0 = args[0].(context.Context) | |
| 1485 | 2255 | } | |
| 1486 | - | var arg1 string | |
| 2256 | + | var arg1 port.MenuAction | |
| 1487 | 2257 | if args[1] != nil { | |
| 1488 | - | arg1 = args[1].(string) | |
| 2258 | + | arg1 = args[1].(port.MenuAction) | |
| 2259 | + | } | |
| 2260 | + | var arg2 port.MenuContext | |
| 2261 | + | if args[2] != nil { | |
| 2262 | + | arg2 = args[2].(port.MenuContext) | |
| 1489 | 2263 | } | |
| 1490 | 2264 | run( | |
| 1491 | 2265 | arg0, | |
| 1492 | 2266 | arg1, | |
| 2267 | + | arg2, | |
| 1493 | 2268 | ) | |
| 1494 | 2269 | }) | |
| 1495 | 2270 | return _c | |
| 1496 | 2271 | } | |
| 1497 | 2272 | ||
| 1498 | - | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) Return(err error) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 2273 | + | func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Return(err error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call { | |
| 1499 | 2274 | _c.Call.Return(err) | |
| 1500 | 2275 | return _c | |
| 1501 | 2276 | } | |
| 1502 | 2277 | ||
| 1503 | - | func (_c *MockContentInjector_InjectFindHighlightCSS_Call) RunAndReturn(run func(ctx context.Context, css string) error) *MockContentInjector_InjectFindHighlightCSS_Call { | |
| 2278 | + | func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call { | |
| 1504 | 2279 | _c.Call.Return(run) | |
| 1505 | 2280 | return _c | |
| 1506 | 2281 | } | |
| 1507 | 2282 | ||
| 1508 | - | // InjectThemeCSS provides a mock function for the type MockContentInjector | |
| 1509 | - | func (_mock *MockContentInjector) InjectThemeCSS(ctx context.Context, css string) error { | |
| 1510 | - | ret := _mock.Called(ctx, css) | |
| 2283 | + | // NewMockContextMenuActionExecutorFactory creates a new instance of MockContextMenuActionExecutorFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2284 | + | // The first argument is typically a *testing.T value. | |
| 2285 | + | func NewMockContextMenuActionExecutorFactory(t interface { | |
| 2286 | + | mock.TestingT | |
| 2287 | + | Cleanup(func()) | |
| 2288 | + | }) *MockContextMenuActionExecutorFactory { | |
| 2289 | + | mock := &MockContextMenuActionExecutorFactory{} | |
| 2290 | + | mock.Mock.Test(t) | |
| 2291 | + | ||
| 2292 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2293 | + | ||
| 2294 | + | return mock | |
| 2295 | + | } | |
| 2296 | + | ||
| 2297 | + | // MockContextMenuActionExecutorFactory is an autogenerated mock type for the ContextMenuActionExecutorFactory type | |
| 2298 | + | type MockContextMenuActionExecutorFactory struct { | |
| 2299 | + | mock.Mock | |
| 2300 | + | } | |
| 2301 | + | ||
| 2302 | + | type MockContextMenuActionExecutorFactory_Expecter struct { | |
| 2303 | + | mock *mock.Mock | |
| 2304 | + | } | |
| 2305 | + | ||
| 2306 | + | func (_m *MockContextMenuActionExecutorFactory) EXPECT() *MockContextMenuActionExecutorFactory_Expecter { | |
| 2307 | + | return &MockContextMenuActionExecutorFactory_Expecter{mock: &_m.Mock} | |
| 2308 | + | } | |
| 2309 | + | ||
| 2310 | + | // NewContextMenuActionExecutor provides a mock function for the type MockContextMenuActionExecutorFactory | |
| 2311 | + | func (_mock *MockContextMenuActionExecutorFactory) NewContextMenuActionExecutor(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor { | |
| 2312 | + | ret := _mock.Called(clipboard, resolver, saver, delegator) | |
| 1511 | 2313 | ||
| 1512 | 2314 | if len(ret) == 0 { | |
| 1513 | - | panic("no return value specified for InjectThemeCSS") | |
| 2315 | + | panic("no return value specified for NewContextMenuActionExecutor") | |
| 1514 | 2316 | } | |
| 1515 | 2317 | ||
| 1516 | - | var r0 error | |
| 1517 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 1518 | - | r0 = returnFunc(ctx, css) | |
| 2318 | + | var r0 port.ContextMenuActionExecutor | |
| 2319 | + | if returnFunc, ok := ret.Get(0).(func(port.Clipboard, port.ImageDataResolver, port.ResolvedImageSaver, port.MenuActionDelegator) port.ContextMenuActionExecutor); ok { | |
| 2320 | + | r0 = returnFunc(clipboard, resolver, saver, delegator) | |
| 1519 | 2321 | } else { | |
| 1520 | - | r0 = ret.Error(0) | |
| 2322 | + | if ret.Get(0) != nil { | |
| 2323 | + | r0 = ret.Get(0).(port.ContextMenuActionExecutor) | |
| 2324 | + | } | |
| 1521 | 2325 | } | |
| 1522 | 2326 | return r0 | |
| 1523 | 2327 | } | |
| 1524 | 2328 | ||
| 1525 | - | // MockContentInjector_InjectThemeCSS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InjectThemeCSS' | |
| 1526 | - | type MockContentInjector_InjectThemeCSS_Call struct { | |
| 2329 | + | // MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewContextMenuActionExecutor' | |
| 2330 | + | type MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call struct { | |
| 1527 | 2331 | *mock.Call | |
| 1528 | 2332 | } | |
| 1529 | 2333 | ||
| 1530 | - | // InjectThemeCSS is a helper method to define mock.On call | |
| 1531 | - | // - ctx context.Context | |
| 1532 | - | // - css string | |
| 1533 | - | func (_e *MockContentInjector_Expecter) InjectThemeCSS(ctx interface{}, css interface{}) *MockContentInjector_InjectThemeCSS_Call { | |
| 1534 | - | return &MockContentInjector_InjectThemeCSS_Call{Call: _e.mock.On("InjectThemeCSS", ctx, css)} | |
| 2334 | + | // NewContextMenuActionExecutor is a helper method to define mock.On call | |
| 2335 | + | // - clipboard port.Clipboard | |
| 2336 | + | // - resolver port.ImageDataResolver | |
| 2337 | + | // - saver port.ResolvedImageSaver | |
| 2338 | + | // - delegator port.MenuActionDelegator | |
| 2339 | + | func (_e *MockContextMenuActionExecutorFactory_Expecter) NewContextMenuActionExecutor(clipboard interface{}, resolver interface{}, saver interface{}, delegator interface{}) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call { | |
| 2340 | + | return &MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call{Call: _e.mock.On("NewContextMenuActionExecutor", clipboard, resolver, saver, delegator)} | |
| 1535 | 2341 | } | |
| 1536 | 2342 | ||
| 1537 | - | func (_c *MockContentInjector_InjectThemeCSS_Call) Run(run func(ctx context.Context, css string)) *MockContentInjector_InjectThemeCSS_Call { | |
| 2343 | + | func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Run(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator)) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call { | |
| 1538 | 2344 | _c.Call.Run(func(args mock.Arguments) { | |
| 1539 | - | var arg0 context.Context | |
| 2345 | + | var arg0 port.Clipboard | |
| 1540 | 2346 | if args[0] != nil { | |
| 1541 | - | arg0 = args[0].(context.Context) | |
| 2347 | + | arg0 = args[0].(port.Clipboard) | |
| 1542 | 2348 | } | |
| 1543 | - | var arg1 string | |
| 2349 | + | var arg1 port.ImageDataResolver | |
| 1544 | 2350 | if args[1] != nil { | |
| 1545 | - | arg1 = args[1].(string) | |
| 2351 | + | arg1 = args[1].(port.ImageDataResolver) | |
| 2352 | + | } | |
| 2353 | + | var arg2 port.ResolvedImageSaver | |
| 2354 | + | if args[2] != nil { | |
| 2355 | + | arg2 = args[2].(port.ResolvedImageSaver) | |
| 2356 | + | } | |
| 2357 | + | var arg3 port.MenuActionDelegator | |
| 2358 | + | if args[3] != nil { | |
| 2359 | + | arg3 = args[3].(port.MenuActionDelegator) | |
| 1546 | 2360 | } | |
| 1547 | 2361 | run( | |
| 1548 | 2362 | arg0, | |
| 1549 | 2363 | arg1, | |
| 2364 | + | arg2, | |
| 2365 | + | arg3, | |
| 1550 | 2366 | ) | |
| 1551 | 2367 | }) | |
| 1552 | 2368 | return _c | |
| 1553 | 2369 | } | |
| 1554 | 2370 | ||
| 1555 | - | func (_c *MockContentInjector_InjectThemeCSS_Call) Return(err error) *MockContentInjector_InjectThemeCSS_Call { | |
| 1556 | - | _c.Call.Return(err) | |
| 2371 | + | func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) Return(contextMenuActionExecutor port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call { | |
| 2372 | + | _c.Call.Return(contextMenuActionExecutor) | |
| 1557 | 2373 | return _c | |
| 1558 | 2374 | } | |
| 1559 | 2375 | ||
| 1560 | - | func (_c *MockContentInjector_InjectThemeCSS_Call) RunAndReturn(run func(ctx context.Context, css string) error) *MockContentInjector_InjectThemeCSS_Call { | |
| 2376 | + | func (_c *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call) RunAndReturn(run func(clipboard port.Clipboard, resolver port.ImageDataResolver, saver port.ResolvedImageSaver, delegator port.MenuActionDelegator) port.ContextMenuActionExecutor) *MockContextMenuActionExecutorFactory_NewContextMenuActionExecutor_Call { | |
| 1561 | 2377 | _c.Call.Return(run) | |
| 1562 | 2378 | return _c | |
| 1563 | 2379 | } | |
| 1564 | 2380 | ||
| 1565 | - | // RefreshScripts provides a mock function for the type MockContentInjector | |
| 1566 | - | func (_mock *MockContentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error { | |
| 1567 | - | ret := _mock.Called(ctx, wv) | |
| 2381 | + | // NewMockMenuActionDelegator creates a new instance of MockMenuActionDelegator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2382 | + | // The first argument is typically a *testing.T value. | |
| 2383 | + | func NewMockMenuActionDelegator(t interface { | |
| 2384 | + | mock.TestingT | |
| 2385 | + | Cleanup(func()) | |
| 2386 | + | }) *MockMenuActionDelegator { | |
| 2387 | + | mock := &MockMenuActionDelegator{} | |
| 2388 | + | mock.Mock.Test(t) | |
| 2389 | + | ||
| 2390 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2391 | + | ||
| 2392 | + | return mock | |
| 2393 | + | } | |
| 2394 | + | ||
| 2395 | + | // MockMenuActionDelegator is an autogenerated mock type for the MenuActionDelegator type | |
| 2396 | + | type MockMenuActionDelegator struct { | |
| 2397 | + | mock.Mock | |
| 2398 | + | } | |
| 2399 | + | ||
| 2400 | + | type MockMenuActionDelegator_Expecter struct { | |
| 2401 | + | mock *mock.Mock | |
| 2402 | + | } | |
| 2403 | + | ||
| 2404 | + | func (_m *MockMenuActionDelegator) EXPECT() *MockMenuActionDelegator_Expecter { | |
| 2405 | + | return &MockMenuActionDelegator_Expecter{mock: &_m.Mock} | |
| 2406 | + | } | |
| 2407 | + | ||
| 2408 | + | // DelegateMenuAction provides a mock function for the type MockMenuActionDelegator | |
| 2409 | + | func (_mock *MockMenuActionDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 2410 | + | ret := _mock.Called(ctx, action, menuContext) | |
| 1568 | 2411 | ||
| 1569 | 2412 | if len(ret) == 0 { | |
| 1570 | - | panic("no return value specified for RefreshScripts") | |
| 2413 | + | panic("no return value specified for DelegateMenuAction") | |
| 1571 | 2414 | } | |
| 1572 | 2415 | ||
| 1573 | 2416 | var r0 error | |
| 1574 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebView) error); ok { | |
| 1575 | - | r0 = returnFunc(ctx, wv) | |
| 2417 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.MenuAction, port.MenuContext) error); ok { | |
| 2418 | + | r0 = returnFunc(ctx, action, menuContext) | |
| 1576 | 2419 | } else { | |
| 1577 | 2420 | r0 = ret.Error(0) | |
| 1578 | 2421 | } | |
| 1579 | 2422 | return r0 | |
| 1580 | 2423 | } | |
| 1581 | 2424 | ||
| 1582 | - | // MockContentInjector_RefreshScripts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshScripts' | |
| 1583 | - | type MockContentInjector_RefreshScripts_Call struct { | |
| 2425 | + | // MockMenuActionDelegator_DelegateMenuAction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelegateMenuAction' | |
| 2426 | + | type MockMenuActionDelegator_DelegateMenuAction_Call struct { | |
| 1584 | 2427 | *mock.Call | |
| 1585 | 2428 | } | |
| 1586 | 2429 | ||
| 1587 | - | // RefreshScripts is a helper method to define mock.On call | |
| 2430 | + | // DelegateMenuAction is a helper method to define mock.On call | |
| 1588 | 2431 | // - ctx context.Context | |
| 1589 | - | // - wv port.WebView | |
| 1590 | - | func (_e *MockContentInjector_Expecter) RefreshScripts(ctx interface{}, wv interface{}) *MockContentInjector_RefreshScripts_Call { | |
| 1591 | - | return &MockContentInjector_RefreshScripts_Call{Call: _e.mock.On("RefreshScripts", ctx, wv)} | |
| 2432 | + | // - action port.MenuAction | |
| 2433 | + | // - menuContext port.MenuContext | |
| 2434 | + | func (_e *MockMenuActionDelegator_Expecter) DelegateMenuAction(ctx interface{}, action interface{}, menuContext interface{}) *MockMenuActionDelegator_DelegateMenuAction_Call { | |
| 2435 | + | return &MockMenuActionDelegator_DelegateMenuAction_Call{Call: _e.mock.On("DelegateMenuAction", ctx, action, menuContext)} | |
| 1592 | 2436 | } | |
| 1593 | 2437 | ||
| 1594 | - | func (_c *MockContentInjector_RefreshScripts_Call) Run(run func(ctx context.Context, wv port.WebView)) *MockContentInjector_RefreshScripts_Call { | |
| 2438 | + | func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Run(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext)) *MockMenuActionDelegator_DelegateMenuAction_Call { | |
| 1595 | 2439 | _c.Call.Run(func(args mock.Arguments) { | |
| 1596 | 2440 | var arg0 context.Context | |
| 1597 | 2441 | if args[0] != nil { | |
| 1598 | 2442 | arg0 = args[0].(context.Context) | |
| 1599 | 2443 | } | |
| 1600 | - | var arg1 port.WebView | |
| 2444 | + | var arg1 port.MenuAction | |
| 1601 | 2445 | if args[1] != nil { | |
| 1602 | - | arg1 = args[1].(port.WebView) | |
| 2446 | + | arg1 = args[1].(port.MenuAction) | |
| 2447 | + | } | |
| 2448 | + | var arg2 port.MenuContext | |
| 2449 | + | if args[2] != nil { | |
| 2450 | + | arg2 = args[2].(port.MenuContext) | |
| 1603 | 2451 | } | |
| 1604 | 2452 | run( | |
| 1605 | 2453 | arg0, | |
| 1606 | 2454 | arg1, | |
| 2455 | + | arg2, | |
| 1607 | 2456 | ) | |
| 1608 | 2457 | }) | |
| 1609 | 2458 | return _c | |
| 1610 | 2459 | } | |
| 1611 | 2460 | ||
| 1612 | - | func (_c *MockContentInjector_RefreshScripts_Call) Return(err error) *MockContentInjector_RefreshScripts_Call { | |
| 2461 | + | func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Return(err error) *MockMenuActionDelegator_DelegateMenuAction_Call { | |
| 1613 | 2462 | _c.Call.Return(err) | |
| 1614 | 2463 | return _c | |
| 1615 | 2464 | } | |
| 1616 | 2465 | ||
| 1617 | - | func (_c *MockContentInjector_RefreshScripts_Call) RunAndReturn(run func(ctx context.Context, wv port.WebView) error) *MockContentInjector_RefreshScripts_Call { | |
| 2466 | + | func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) RunAndReturn(run func(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error) *MockMenuActionDelegator_DelegateMenuAction_Call { | |
| 1618 | 2467 | _c.Call.Return(run) | |
| 1619 | 2468 | return _c | |
| 1620 | 2469 | } | |
| @@ -7236,63 +8085,246 @@ func (_c *MockIdleInhibitor_IsInhibited_Call) Run(run func()) *MockIdleInhibitor | |||
| 7236 | 8085 | return _c | |
| 7237 | 8086 | } | |
| 7238 | 8087 | ||
| 7239 | - | func (_c *MockIdleInhibitor_IsInhibited_Call) Return(b bool) *MockIdleInhibitor_IsInhibited_Call { | |
| 7240 | - | _c.Call.Return(b) | |
| 8088 | + | func (_c *MockIdleInhibitor_IsInhibited_Call) Return(b bool) *MockIdleInhibitor_IsInhibited_Call { | |
| 8089 | + | _c.Call.Return(b) | |
| 8090 | + | return _c | |
| 8091 | + | } | |
| 8092 | + | ||
| 8093 | + | func (_c *MockIdleInhibitor_IsInhibited_Call) RunAndReturn(run func() bool) *MockIdleInhibitor_IsInhibited_Call { | |
| 8094 | + | _c.Call.Return(run) | |
| 8095 | + | return _c | |
| 8096 | + | } | |
| 8097 | + | ||
| 8098 | + | // Uninhibit provides a mock function for the type MockIdleInhibitor | |
| 8099 | + | func (_mock *MockIdleInhibitor) Uninhibit(ctx context.Context) error { | |
| 8100 | + | ret := _mock.Called(ctx) | |
| 8101 | + | ||
| 8102 | + | if len(ret) == 0 { | |
| 8103 | + | panic("no return value specified for Uninhibit") | |
| 8104 | + | } | |
| 8105 | + | ||
| 8106 | + | var r0 error | |
| 8107 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 8108 | + | r0 = returnFunc(ctx) | |
| 8109 | + | } else { | |
| 8110 | + | r0 = ret.Error(0) | |
| 8111 | + | } | |
| 8112 | + | return r0 | |
| 8113 | + | } | |
| 8114 | + | ||
| 8115 | + | // MockIdleInhibitor_Uninhibit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Uninhibit' | |
| 8116 | + | type MockIdleInhibitor_Uninhibit_Call struct { | |
| 8117 | + | *mock.Call | |
| 8118 | + | } | |
| 8119 | + | ||
| 8120 | + | // Uninhibit is a helper method to define mock.On call | |
| 8121 | + | // - ctx context.Context | |
| 8122 | + | func (_e *MockIdleInhibitor_Expecter) Uninhibit(ctx interface{}) *MockIdleInhibitor_Uninhibit_Call { | |
| 8123 | + | return &MockIdleInhibitor_Uninhibit_Call{Call: _e.mock.On("Uninhibit", ctx)} | |
| 8124 | + | } | |
| 8125 | + | ||
| 8126 | + | func (_c *MockIdleInhibitor_Uninhibit_Call) Run(run func(ctx context.Context)) *MockIdleInhibitor_Uninhibit_Call { | |
| 8127 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 8128 | + | var arg0 context.Context | |
| 8129 | + | if args[0] != nil { | |
| 8130 | + | arg0 = args[0].(context.Context) | |
| 8131 | + | } | |
| 8132 | + | run( | |
| 8133 | + | arg0, | |
| 8134 | + | ) | |
| 8135 | + | }) | |
| 8136 | + | return _c | |
| 8137 | + | } | |
| 8138 | + | ||
| 8139 | + | func (_c *MockIdleInhibitor_Uninhibit_Call) Return(err error) *MockIdleInhibitor_Uninhibit_Call { | |
| 8140 | + | _c.Call.Return(err) | |
| 8141 | + | return _c | |
| 8142 | + | } | |
| 8143 | + | ||
| 8144 | + | func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Context) error) *MockIdleInhibitor_Uninhibit_Call { | |
| 8145 | + | _c.Call.Return(run) | |
| 8146 | + | return _c | |
| 8147 | + | } | |
| 8148 | + | ||
| 8149 | + | // NewMockImageDataResolver creates a new instance of MockImageDataResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 8150 | + | // The first argument is typically a *testing.T value. | |
| 8151 | + | func NewMockImageDataResolver(t interface { | |
| 8152 | + | mock.TestingT | |
| 8153 | + | Cleanup(func()) | |
| 8154 | + | }) *MockImageDataResolver { | |
| 8155 | + | mock := &MockImageDataResolver{} | |
| 8156 | + | mock.Mock.Test(t) | |
| 8157 | + | ||
| 8158 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 8159 | + | ||
| 8160 | + | return mock | |
| 8161 | + | } | |
| 8162 | + | ||
| 8163 | + | // MockImageDataResolver is an autogenerated mock type for the ImageDataResolver type | |
| 8164 | + | type MockImageDataResolver struct { | |
| 8165 | + | mock.Mock | |
| 8166 | + | } | |
| 8167 | + | ||
| 8168 | + | type MockImageDataResolver_Expecter struct { | |
| 8169 | + | mock *mock.Mock | |
| 8170 | + | } | |
| 8171 | + | ||
| 8172 | + | func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter { | |
| 8173 | + | return &MockImageDataResolver_Expecter{mock: &_m.Mock} | |
| 8174 | + | } | |
| 8175 | + | ||
| 8176 | + | // ResolveImageData provides a mock function for the type MockImageDataResolver | |
| 8177 | + | func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | |
| 8178 | + | ret := _mock.Called(ctx, uri) | |
| 8179 | + | ||
| 8180 | + | if len(ret) == 0 { | |
| 8181 | + | panic("no return value specified for ResolveImageData") | |
| 8182 | + | } | |
| 8183 | + | ||
| 8184 | + | var r0 entity.ImageData | |
| 8185 | + | var r1 error | |
| 8186 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok { | |
| 8187 | + | return returnFunc(ctx, uri) | |
| 8188 | + | } | |
| 8189 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok { | |
| 8190 | + | r0 = returnFunc(ctx, uri) | |
| 8191 | + | } else { | |
| 8192 | + | r0 = ret.Get(0).(entity.ImageData) | |
| 8193 | + | } | |
| 8194 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 8195 | + | r1 = returnFunc(ctx, uri) | |
| 8196 | + | } else { | |
| 8197 | + | r1 = ret.Error(1) | |
| 8198 | + | } | |
| 8199 | + | return r0, r1 | |
| 8200 | + | } | |
| 8201 | + | ||
| 8202 | + | // MockImageDataResolver_ResolveImageData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveImageData' | |
| 8203 | + | type MockImageDataResolver_ResolveImageData_Call struct { | |
| 8204 | + | *mock.Call | |
| 8205 | + | } | |
| 8206 | + | ||
| 8207 | + | // ResolveImageData is a helper method to define mock.On call | |
| 8208 | + | // - ctx context.Context | |
| 8209 | + | // - uri string | |
| 8210 | + | func (_e *MockImageDataResolver_Expecter) ResolveImageData(ctx interface{}, uri interface{}) *MockImageDataResolver_ResolveImageData_Call { | |
| 8211 | + | return &MockImageDataResolver_ResolveImageData_Call{Call: _e.mock.On("ResolveImageData", ctx, uri)} | |
| 8212 | + | } | |
| 8213 | + | ||
| 8214 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.Context, uri string)) *MockImageDataResolver_ResolveImageData_Call { | |
| 8215 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 8216 | + | var arg0 context.Context | |
| 8217 | + | if args[0] != nil { | |
| 8218 | + | arg0 = args[0].(context.Context) | |
| 8219 | + | } | |
| 8220 | + | var arg1 string | |
| 8221 | + | if args[1] != nil { | |
| 8222 | + | arg1 = args[1].(string) | |
| 8223 | + | } | |
| 8224 | + | run( | |
| 8225 | + | arg0, | |
| 8226 | + | arg1, | |
| 8227 | + | ) | |
| 8228 | + | }) | |
| 8229 | + | return _c | |
| 8230 | + | } | |
| 8231 | + | ||
| 8232 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call { | |
| 8233 | + | _c.Call.Return(imageData, err) | |
| 7241 | 8234 | return _c | |
| 7242 | 8235 | } | |
| 7243 | 8236 | ||
| 7244 | - | func (_c *MockIdleInhibitor_IsInhibited_Call) RunAndReturn(run func() bool) *MockIdleInhibitor_IsInhibited_Call { | |
| 8237 | + | func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call { | |
| 7245 | 8238 | _c.Call.Return(run) | |
| 7246 | 8239 | return _c | |
| 7247 | 8240 | } | |
| 7248 | 8241 | ||
| 7249 | - | // Uninhibit provides a mock function for the type MockIdleInhibitor | |
| 7250 | - | func (_mock *MockIdleInhibitor) Uninhibit(ctx context.Context) error { | |
| 7251 | - | ret := _mock.Called(ctx) | |
| 8242 | + | // NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 8243 | + | // The first argument is typically a *testing.T value. | |
| 8244 | + | func NewMockResolvedImageSaver(t interface { | |
| 8245 | + | mock.TestingT | |
| 8246 | + | Cleanup(func()) | |
| 8247 | + | }) *MockResolvedImageSaver { | |
| 8248 | + | mock := &MockResolvedImageSaver{} | |
| 8249 | + | mock.Mock.Test(t) | |
| 8250 | + | ||
| 8251 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 8252 | + | ||
| 8253 | + | return mock | |
| 8254 | + | } | |
| 8255 | + | ||
| 8256 | + | // MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type | |
| 8257 | + | type MockResolvedImageSaver struct { | |
| 8258 | + | mock.Mock | |
| 8259 | + | } | |
| 8260 | + | ||
| 8261 | + | type MockResolvedImageSaver_Expecter struct { | |
| 8262 | + | mock *mock.Mock | |
| 8263 | + | } | |
| 8264 | + | ||
| 8265 | + | func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter { | |
| 8266 | + | return &MockResolvedImageSaver_Expecter{mock: &_m.Mock} | |
| 8267 | + | } | |
| 8268 | + | ||
| 8269 | + | // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver | |
| 8270 | + | func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 8271 | + | ret := _mock.Called(ctx, image, menuContext) | |
| 7252 | 8272 | ||
| 7253 | 8273 | if len(ret) == 0 { | |
| 7254 | - | panic("no return value specified for Uninhibit") | |
| 8274 | + | panic("no return value specified for SaveResolvedImage") | |
| 7255 | 8275 | } | |
| 7256 | 8276 | ||
| 7257 | 8277 | var r0 error | |
| 7258 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 7259 | - | r0 = returnFunc(ctx) | |
| 8278 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok { | |
| 8279 | + | r0 = returnFunc(ctx, image, menuContext) | |
| 7260 | 8280 | } else { | |
| 7261 | 8281 | r0 = ret.Error(0) | |
| 7262 | 8282 | } | |
| 7263 | 8283 | return r0 | |
| 7264 | 8284 | } | |
| 7265 | 8285 | ||
| 7266 | - | // MockIdleInhibitor_Uninhibit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Uninhibit' | |
| 7267 | - | type MockIdleInhibitor_Uninhibit_Call struct { | |
| 8286 | + | // MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage' | |
| 8287 | + | type MockResolvedImageSaver_SaveResolvedImage_Call struct { | |
| 7268 | 8288 | *mock.Call | |
| 7269 | 8289 | } | |
| 7270 | 8290 | ||
| 7271 | - | // Uninhibit is a helper method to define mock.On call | |
| 8291 | + | // SaveResolvedImage is a helper method to define mock.On call | |
| 7272 | 8292 | // - ctx context.Context | |
| 7273 | - | func (_e *MockIdleInhibitor_Expecter) Uninhibit(ctx interface{}) *MockIdleInhibitor_Uninhibit_Call { | |
| 7274 | - | return &MockIdleInhibitor_Uninhibit_Call{Call: _e.mock.On("Uninhibit", ctx)} | |
| 8293 | + | // - image entity.ImageData | |
| 8294 | + | // - menuContext port.MenuContext | |
| 8295 | + | func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 8296 | + | return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)} | |
| 7275 | 8297 | } | |
| 7276 | 8298 | ||
| 7277 | - | func (_c *MockIdleInhibitor_Uninhibit_Call) Run(run func(ctx context.Context)) *MockIdleInhibitor_Uninhibit_Call { | |
| 8299 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7278 | 8300 | _c.Call.Run(func(args mock.Arguments) { | |
| 7279 | 8301 | var arg0 context.Context | |
| 7280 | 8302 | if args[0] != nil { | |
| 7281 | 8303 | arg0 = args[0].(context.Context) | |
| 7282 | 8304 | } | |
| 8305 | + | var arg1 entity.ImageData | |
| 8306 | + | if args[1] != nil { | |
| 8307 | + | arg1 = args[1].(entity.ImageData) | |
| 8308 | + | } | |
| 8309 | + | var arg2 port.MenuContext | |
| 8310 | + | if args[2] != nil { | |
| 8311 | + | arg2 = args[2].(port.MenuContext) | |
| 8312 | + | } | |
| 7283 | 8313 | run( | |
| 7284 | 8314 | arg0, | |
| 8315 | + | arg1, | |
| 8316 | + | arg2, | |
| 7285 | 8317 | ) | |
| 7286 | 8318 | }) | |
| 7287 | 8319 | return _c | |
| 7288 | 8320 | } | |
| 7289 | 8321 | ||
| 7290 | - | func (_c *MockIdleInhibitor_Uninhibit_Call) Return(err error) *MockIdleInhibitor_Uninhibit_Call { | |
| 8322 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7291 | 8323 | _c.Call.Return(err) | |
| 7292 | 8324 | return _c | |
| 7293 | 8325 | } | |
| 7294 | 8326 | ||
| 7295 | - | func (_c *MockIdleInhibitor_Uninhibit_Call) RunAndReturn(run func(ctx context.Context) error) *MockIdleInhibitor_Uninhibit_Call { | |
| 8327 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 7296 | 8328 | _c.Call.Return(run) | |
| 7297 | 8329 | return _c | |
| 7298 | 8330 | } | |
| @@ -13298,13 +14330,13 @@ func (_c *MockPrinter_PrintPage_Call) RunAndReturn(run func()) *MockPrinter_Prin | |||
| 13298 | 14330 | return _c | |
| 13299 | 14331 | } | |
| 13300 | 14332 | ||
| 13301 | - | // NewMockPopupCapable creates a new instance of MockPopupCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14333 | + | // NewMockPopupLifecycleCapable creates a new instance of MockPopupLifecycleCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13302 | 14334 | // The first argument is typically a *testing.T value. | |
| 13303 | - | func NewMockPopupCapable(t interface { | |
| 14335 | + | func NewMockPopupLifecycleCapable(t interface { | |
| 13304 | 14336 | mock.TestingT | |
| 13305 | 14337 | Cleanup(func()) | |
| 13306 | - | }) *MockPopupCapable { | |
| 13307 | - | mock := &MockPopupCapable{} | |
| 14338 | + | }) *MockPopupLifecycleCapable { | |
| 14339 | + | mock := &MockPopupLifecycleCapable{} | |
| 13308 | 14340 | mock.Mock.Test(t) | |
| 13309 | 14341 | ||
| 13310 | 14342 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -13312,37 +14344,77 @@ func NewMockPopupCapable(t interface { | |||
| 13312 | 14344 | return mock | |
| 13313 | 14345 | } | |
| 13314 | 14346 | ||
| 13315 | - | // MockPopupCapable is an autogenerated mock type for the PopupCapable type | |
| 13316 | - | type MockPopupCapable struct { | |
| 14347 | + | // MockPopupLifecycleCapable is an autogenerated mock type for the PopupLifecycleCapable type | |
| 14348 | + | type MockPopupLifecycleCapable struct { | |
| 13317 | 14349 | mock.Mock | |
| 13318 | 14350 | } | |
| 13319 | 14351 | ||
| 13320 | - | type MockPopupCapable_Expecter struct { | |
| 14352 | + | type MockPopupLifecycleCapable_Expecter struct { | |
| 13321 | 14353 | mock *mock.Mock | |
| 13322 | 14354 | } | |
| 13323 | 14355 | ||
| 13324 | - | func (_m *MockPopupCapable) EXPECT() *MockPopupCapable_Expecter { | |
| 13325 | - | return &MockPopupCapable_Expecter{mock: &_m.Mock} | |
| 14356 | + | func (_m *MockPopupLifecycleCapable) EXPECT() *MockPopupLifecycleCapable_Expecter { | |
| 14357 | + | return &MockPopupLifecycleCapable_Expecter{mock: &_m.Mock} | |
| 14358 | + | } | |
| 14359 | + | ||
| 14360 | + | // PrimePopupNavigation provides a mock function for the type MockPopupLifecycleCapable | |
| 14361 | + | func (_mock *MockPopupLifecycleCapable) PrimePopupNavigation(uri string) { | |
| 14362 | + | _mock.Called(uri) | |
| 14363 | + | return | |
| 14364 | + | } | |
| 14365 | + | ||
| 14366 | + | // MockPopupLifecycleCapable_PrimePopupNavigation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrimePopupNavigation' | |
| 14367 | + | type MockPopupLifecycleCapable_PrimePopupNavigation_Call struct { | |
| 14368 | + | *mock.Call | |
| 14369 | + | } | |
| 14370 | + | ||
| 14371 | + | // PrimePopupNavigation is a helper method to define mock.On call | |
| 14372 | + | // - uri string | |
| 14373 | + | func (_e *MockPopupLifecycleCapable_Expecter) PrimePopupNavigation(uri interface{}) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14374 | + | return &MockPopupLifecycleCapable_PrimePopupNavigation_Call{Call: _e.mock.On("PrimePopupNavigation", uri)} | |
| 14375 | + | } | |
| 14376 | + | ||
| 14377 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Run(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14378 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14379 | + | var arg0 string | |
| 14380 | + | if args[0] != nil { | |
| 14381 | + | arg0 = args[0].(string) | |
| 14382 | + | } | |
| 14383 | + | run( | |
| 14384 | + | arg0, | |
| 14385 | + | ) | |
| 14386 | + | }) | |
| 14387 | + | return _c | |
| 14388 | + | } | |
| 14389 | + | ||
| 14390 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) Return() *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14391 | + | _c.Call.Return() | |
| 14392 | + | return _c | |
| 14393 | + | } | |
| 14394 | + | ||
| 14395 | + | func (_c *MockPopupLifecycleCapable_PrimePopupNavigation_Call) RunAndReturn(run func(uri string)) *MockPopupLifecycleCapable_PrimePopupNavigation_Call { | |
| 14396 | + | _c.Run(run) | |
| 14397 | + | return _c | |
| 13326 | 14398 | } | |
| 13327 | 14399 | ||
| 13328 | - | // SetOnClose provides a mock function for the type MockPopupCapable | |
| 13329 | - | func (_mock *MockPopupCapable) SetOnClose(fn func()) { | |
| 14400 | + | // SetOnClose provides a mock function for the type MockPopupLifecycleCapable | |
| 14401 | + | func (_mock *MockPopupLifecycleCapable) SetOnClose(fn func()) { | |
| 13330 | 14402 | _mock.Called(fn) | |
| 13331 | 14403 | return | |
| 13332 | 14404 | } | |
| 13333 | 14405 | ||
| 13334 | - | // MockPopupCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose' | |
| 13335 | - | type MockPopupCapable_SetOnClose_Call struct { | |
| 14406 | + | // MockPopupLifecycleCapable_SetOnClose_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnClose' | |
| 14407 | + | type MockPopupLifecycleCapable_SetOnClose_Call struct { | |
| 13336 | 14408 | *mock.Call | |
| 13337 | 14409 | } | |
| 13338 | 14410 | ||
| 13339 | 14411 | // SetOnClose is a helper method to define mock.On call | |
| 13340 | 14412 | // - fn func() | |
| 13341 | - | func (_e *MockPopupCapable_Expecter) SetOnClose(fn interface{}) *MockPopupCapable_SetOnClose_Call { | |
| 13342 | - | return &MockPopupCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)} | |
| 14413 | + | func (_e *MockPopupLifecycleCapable_Expecter) SetOnClose(fn interface{}) *MockPopupLifecycleCapable_SetOnClose_Call { | |
| 14414 | + | return &MockPopupLifecycleCapable_SetOnClose_Call{Call: _e.mock.On("SetOnClose", fn)} | |
| 13343 | 14415 | } | |
| 13344 | 14416 | ||
| 13345 | - | func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupCapable_SetOnClose_Call { | |
| 14417 | + | func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupLifecycleCapable_SetOnClose_Call { | |
| 13346 | 14418 | _c.Call.Run(func(args mock.Arguments) { | |
| 13347 | 14419 | var arg0 func() | |
| 13348 | 14420 | if args[0] != nil { | |
| @@ -13355,34 +14427,34 @@ func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupC | |||
| 13355 | 14427 | return _c | |
| 13356 | 14428 | } | |
| 13357 | 14429 | ||
| 13358 | - | func (_c *MockPopupCapable_SetOnClose_Call) Return() *MockPopupCapable_SetOnClose_Call { | |
| 14430 | + | func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Return() *MockPopupLifecycleCapable_SetOnClose_Call { | |
| 13359 | 14431 | _c.Call.Return() | |
| 13360 | 14432 | return _c | |
| 13361 | 14433 | } | |
| 13362 | 14434 | ||
| 13363 | - | func (_c *MockPopupCapable_SetOnClose_Call) RunAndReturn(run func(fn func())) *MockPopupCapable_SetOnClose_Call { | |
| 14435 | + | func (_c *MockPopupLifecycleCapable_SetOnClose_Call) RunAndReturn(run func(fn func())) *MockPopupLifecycleCapable_SetOnClose_Call { | |
| 13364 | 14436 | _c.Run(run) | |
| 13365 | 14437 | return _c | |
| 13366 | 14438 | } | |
| 13367 | 14439 | ||
| 13368 | - | // SetOnReadyToShow provides a mock function for the type MockPopupCapable | |
| 13369 | - | func (_mock *MockPopupCapable) SetOnReadyToShow(fn func()) { | |
| 14440 | + | // SetOnReadyToShow provides a mock function for the type MockPopupLifecycleCapable | |
| 14441 | + | func (_mock *MockPopupLifecycleCapable) SetOnReadyToShow(fn func()) { | |
| 13370 | 14442 | _mock.Called(fn) | |
| 13371 | 14443 | return | |
| 13372 | 14444 | } | |
| 13373 | 14445 | ||
| 13374 | - | // MockPopupCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow' | |
| 13375 | - | type MockPopupCapable_SetOnReadyToShow_Call struct { | |
| 14446 | + | // MockPopupLifecycleCapable_SetOnReadyToShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOnReadyToShow' | |
| 14447 | + | type MockPopupLifecycleCapable_SetOnReadyToShow_Call struct { | |
| 13376 | 14448 | *mock.Call | |
| 13377 | 14449 | } | |
| 13378 | 14450 | ||
| 13379 | 14451 | // SetOnReadyToShow is a helper method to define mock.On call | |
| 13380 | 14452 | // - fn func() | |
| 13381 | - | func (_e *MockPopupCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupCapable_SetOnReadyToShow_Call { | |
| 13382 | - | return &MockPopupCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)} | |
| 14453 | + | func (_e *MockPopupLifecycleCapable_Expecter) SetOnReadyToShow(fn interface{}) *MockPopupLifecycleCapable_SetOnReadyToShow_Call { | |
| 14454 | + | return &MockPopupLifecycleCapable_SetOnReadyToShow_Call{Call: _e.mock.On("SetOnReadyToShow", fn)} | |
| 13383 | 14455 | } | |
| 13384 | 14456 | ||
| 13385 | - | func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *MockPopupCapable_SetOnReadyToShow_Call { | |
| 14457 | + | func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *MockPopupLifecycleCapable_SetOnReadyToShow_Call { | |
| 13386 | 14458 | _c.Call.Run(func(args mock.Arguments) { | |
| 13387 | 14459 | var arg0 func() | |
| 13388 | 14460 | if args[0] != nil { | |
| @@ -13395,49 +14467,246 @@ func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *Mock | |||
| 13395 | 14467 | return _c | |
| 13396 | 14468 | } | |
| 13397 | 14469 | ||
| 13398 | - | func (_c *MockPopupCapable_SetOnReadyToShow_Call) Return() *MockPopupCapable_SetOnReadyToShow_Call { | |
| 14470 | + | func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Return() *MockPopupLifecycleCapable_SetOnReadyToShow_Call { | |
| 13399 | 14471 | _c.Call.Return() | |
| 13400 | 14472 | return _c | |
| 13401 | 14473 | } | |
| 13402 | 14474 | ||
| 13403 | - | func (_c *MockPopupCapable_SetOnReadyToShow_Call) RunAndReturn(run func(fn func())) *MockPopupCapable_SetOnReadyToShow_Call { | |
| 14475 | + | func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) RunAndReturn(run func(fn func())) *MockPopupLifecycleCapable_SetOnReadyToShow_Call { | |
| 13404 | 14476 | _c.Run(run) | |
| 13405 | 14477 | return _c | |
| 13406 | 14478 | } | |
| 13407 | 14479 | ||
| 13408 | - | // Show provides a mock function for the type MockPopupCapable | |
| 13409 | - | func (_mock *MockPopupCapable) Show() { | |
| 14480 | + | // Show provides a mock function for the type MockPopupLifecycleCapable | |
| 14481 | + | func (_mock *MockPopupLifecycleCapable) Show() { | |
| 13410 | 14482 | _mock.Called() | |
| 13411 | 14483 | return | |
| 13412 | 14484 | } | |
| 13413 | 14485 | ||
| 13414 | - | // MockPopupCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show' | |
| 13415 | - | type MockPopupCapable_Show_Call struct { | |
| 14486 | + | // MockPopupLifecycleCapable_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show' | |
| 14487 | + | type MockPopupLifecycleCapable_Show_Call struct { | |
| 13416 | 14488 | *mock.Call | |
| 13417 | 14489 | } | |
| 13418 | 14490 | ||
| 13419 | 14491 | // Show is a helper method to define mock.On call | |
| 13420 | - | func (_e *MockPopupCapable_Expecter) Show() *MockPopupCapable_Show_Call { | |
| 13421 | - | return &MockPopupCapable_Show_Call{Call: _e.mock.On("Show")} | |
| 14492 | + | func (_e *MockPopupLifecycleCapable_Expecter) Show() *MockPopupLifecycleCapable_Show_Call { | |
| 14493 | + | return &MockPopupLifecycleCapable_Show_Call{Call: _e.mock.On("Show")} | |
| 13422 | 14494 | } | |
| 13423 | 14495 | ||
| 13424 | - | func (_c *MockPopupCapable_Show_Call) Run(run func()) *MockPopupCapable_Show_Call { | |
| 14496 | + | func (_c *MockPopupLifecycleCapable_Show_Call) Run(run func()) *MockPopupLifecycleCapable_Show_Call { | |
| 13425 | 14497 | _c.Call.Run(func(args mock.Arguments) { | |
| 13426 | 14498 | run() | |
| 13427 | 14499 | }) | |
| 13428 | 14500 | return _c | |
| 13429 | 14501 | } | |
| 13430 | 14502 | ||
| 13431 | - | func (_c *MockPopupCapable_Show_Call) Return() *MockPopupCapable_Show_Call { | |
| 14503 | + | func (_c *MockPopupLifecycleCapable_Show_Call) Return() *MockPopupLifecycleCapable_Show_Call { | |
| 14504 | + | _c.Call.Return() | |
| 14505 | + | return _c | |
| 14506 | + | } | |
| 14507 | + | ||
| 14508 | + | func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPopupLifecycleCapable_Show_Call { | |
| 14509 | + | _c.Run(run) | |
| 14510 | + | return _c | |
| 14511 | + | } | |
| 14512 | + | ||
| 14513 | + | // NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14514 | + | // The first argument is typically a *testing.T value. | |
| 14515 | + | func NewMockPopupOpenerCapable(t interface { | |
| 14516 | + | mock.TestingT | |
| 14517 | + | Cleanup(func()) | |
| 14518 | + | }) *MockPopupOpenerCapable { | |
| 14519 | + | mock := &MockPopupOpenerCapable{} | |
| 14520 | + | mock.Mock.Test(t) | |
| 14521 | + | ||
| 14522 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 14523 | + | ||
| 14524 | + | return mock | |
| 14525 | + | } | |
| 14526 | + | ||
| 14527 | + | // MockPopupOpenerCapable is an autogenerated mock type for the PopupOpenerCapable type | |
| 14528 | + | type MockPopupOpenerCapable struct { | |
| 14529 | + | mock.Mock | |
| 14530 | + | } | |
| 14531 | + | ||
| 14532 | + | type MockPopupOpenerCapable_Expecter struct { | |
| 14533 | + | mock *mock.Mock | |
| 14534 | + | } | |
| 14535 | + | ||
| 14536 | + | func (_m *MockPopupOpenerCapable) EXPECT() *MockPopupOpenerCapable_Expecter { | |
| 14537 | + | return &MockPopupOpenerCapable_Expecter{mock: &_m.Mock} | |
| 14538 | + | } | |
| 14539 | + | ||
| 14540 | + | // AddOpenerMessageCallback provides a mock function for the type MockPopupOpenerCapable | |
| 14541 | + | func (_mock *MockPopupOpenerCapable) AddOpenerMessageCallback(fn func()) { | |
| 14542 | + | _mock.Called(fn) | |
| 14543 | + | return | |
| 14544 | + | } | |
| 14545 | + | ||
| 14546 | + | // MockPopupOpenerCapable_AddOpenerMessageCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOpenerMessageCallback' | |
| 14547 | + | type MockPopupOpenerCapable_AddOpenerMessageCallback_Call struct { | |
| 14548 | + | *mock.Call | |
| 14549 | + | } | |
| 14550 | + | ||
| 14551 | + | // AddOpenerMessageCallback is a helper method to define mock.On call | |
| 14552 | + | // - fn func() | |
| 14553 | + | func (_e *MockPopupOpenerCapable_Expecter) AddOpenerMessageCallback(fn interface{}) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call { | |
| 14554 | + | return &MockPopupOpenerCapable_AddOpenerMessageCallback_Call{Call: _e.mock.On("AddOpenerMessageCallback", fn)} | |
| 14555 | + | } | |
| 14556 | + | ||
| 14557 | + | func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) Run(run func(fn func())) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call { | |
| 14558 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14559 | + | var arg0 func() | |
| 14560 | + | if args[0] != nil { | |
| 14561 | + | arg0 = args[0].(func()) | |
| 14562 | + | } | |
| 14563 | + | run( | |
| 14564 | + | arg0, | |
| 14565 | + | ) | |
| 14566 | + | }) | |
| 14567 | + | return _c | |
| 14568 | + | } | |
| 14569 | + | ||
| 14570 | + | func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) Return() *MockPopupOpenerCapable_AddOpenerMessageCallback_Call { | |
| 14571 | + | _c.Call.Return() | |
| 14572 | + | return _c | |
| 14573 | + | } | |
| 14574 | + | ||
| 14575 | + | func (_c *MockPopupOpenerCapable_AddOpenerMessageCallback_Call) RunAndReturn(run func(fn func())) *MockPopupOpenerCapable_AddOpenerMessageCallback_Call { | |
| 14576 | + | _c.Run(run) | |
| 14577 | + | return _c | |
| 14578 | + | } | |
| 14579 | + | ||
| 14580 | + | // AddOpenerNavigationCallback provides a mock function for the type MockPopupOpenerCapable | |
| 14581 | + | func (_mock *MockPopupOpenerCapable) AddOpenerNavigationCallback(fn func(uri string)) { | |
| 14582 | + | _mock.Called(fn) | |
| 14583 | + | return | |
| 14584 | + | } | |
| 14585 | + | ||
| 14586 | + | // MockPopupOpenerCapable_AddOpenerNavigationCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOpenerNavigationCallback' | |
| 14587 | + | type MockPopupOpenerCapable_AddOpenerNavigationCallback_Call struct { | |
| 14588 | + | *mock.Call | |
| 14589 | + | } | |
| 14590 | + | ||
| 14591 | + | // AddOpenerNavigationCallback is a helper method to define mock.On call | |
| 14592 | + | // - fn func(uri string) | |
| 14593 | + | func (_e *MockPopupOpenerCapable_Expecter) AddOpenerNavigationCallback(fn interface{}) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call { | |
| 14594 | + | return &MockPopupOpenerCapable_AddOpenerNavigationCallback_Call{Call: _e.mock.On("AddOpenerNavigationCallback", fn)} | |
| 14595 | + | } | |
| 14596 | + | ||
| 14597 | + | func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) Run(run func(fn func(uri string))) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call { | |
| 14598 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14599 | + | var arg0 func(uri string) | |
| 14600 | + | if args[0] != nil { | |
| 14601 | + | arg0 = args[0].(func(uri string)) | |
| 14602 | + | } | |
| 14603 | + | run( | |
| 14604 | + | arg0, | |
| 14605 | + | ) | |
| 14606 | + | }) | |
| 14607 | + | return _c | |
| 14608 | + | } | |
| 14609 | + | ||
| 14610 | + | func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) Return() *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call { | |
| 14611 | + | _c.Call.Return() | |
| 14612 | + | return _c | |
| 14613 | + | } | |
| 14614 | + | ||
| 14615 | + | func (_c *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call) RunAndReturn(run func(fn func(uri string))) *MockPopupOpenerCapable_AddOpenerNavigationCallback_Call { | |
| 14616 | + | _c.Run(run) | |
| 14617 | + | return _c | |
| 14618 | + | } | |
| 14619 | + | ||
| 14620 | + | // EnablePopupOpenerBridge provides a mock function for the type MockPopupOpenerCapable | |
| 14621 | + | func (_mock *MockPopupOpenerCapable) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) { | |
| 14622 | + | _mock.Called(parent, noJavaScriptAccess) | |
| 14623 | + | return | |
| 14624 | + | } | |
| 14625 | + | ||
| 14626 | + | // MockPopupOpenerCapable_EnablePopupOpenerBridge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnablePopupOpenerBridge' | |
| 14627 | + | type MockPopupOpenerCapable_EnablePopupOpenerBridge_Call struct { | |
| 14628 | + | *mock.Call | |
| 14629 | + | } | |
| 14630 | + | ||
| 14631 | + | // EnablePopupOpenerBridge is a helper method to define mock.On call | |
| 14632 | + | // - parent port.WebView | |
| 14633 | + | // - noJavaScriptAccess bool | |
| 14634 | + | func (_e *MockPopupOpenerCapable_Expecter) EnablePopupOpenerBridge(parent interface{}, noJavaScriptAccess interface{}) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call { | |
| 14635 | + | return &MockPopupOpenerCapable_EnablePopupOpenerBridge_Call{Call: _e.mock.On("EnablePopupOpenerBridge", parent, noJavaScriptAccess)} | |
| 14636 | + | } | |
| 14637 | + | ||
| 14638 | + | func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) Run(run func(parent port.WebView, noJavaScriptAccess bool)) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call { | |
| 14639 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14640 | + | var arg0 port.WebView | |
| 14641 | + | if args[0] != nil { | |
| 14642 | + | arg0 = args[0].(port.WebView) | |
| 14643 | + | } | |
| 14644 | + | var arg1 bool | |
| 14645 | + | if args[1] != nil { | |
| 14646 | + | arg1 = args[1].(bool) | |
| 14647 | + | } | |
| 14648 | + | run( | |
| 14649 | + | arg0, | |
| 14650 | + | arg1, | |
| 14651 | + | ) | |
| 14652 | + | }) | |
| 14653 | + | return _c | |
| 14654 | + | } | |
| 14655 | + | ||
| 14656 | + | func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) Return() *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call { | |
| 13432 | 14657 | _c.Call.Return() | |
| 13433 | 14658 | return _c | |
| 13434 | 14659 | } | |
| 13435 | 14660 | ||
| 13436 | - | func (_c *MockPopupCapable_Show_Call) RunAndReturn(run func()) *MockPopupCapable_Show_Call { | |
| 14661 | + | func (_c *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call) RunAndReturn(run func(parent port.WebView, noJavaScriptAccess bool)) *MockPopupOpenerCapable_EnablePopupOpenerBridge_Call { | |
| 13437 | 14662 | _c.Run(run) | |
| 13438 | 14663 | return _c | |
| 13439 | 14664 | } | |
| 13440 | 14665 | ||
| 14666 | + | // HasActivePopupOpenerBridge provides a mock function for the type MockPopupOpenerCapable | |
| 14667 | + | func (_mock *MockPopupOpenerCapable) HasActivePopupOpenerBridge() bool { | |
| 14668 | + | ret := _mock.Called() | |
| 14669 | + | ||
| 14670 | + | if len(ret) == 0 { | |
| 14671 | + | panic("no return value specified for HasActivePopupOpenerBridge") | |
| 14672 | + | } | |
| 14673 | + | ||
| 14674 | + | var r0 bool | |
| 14675 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 14676 | + | r0 = returnFunc() | |
| 14677 | + | } else { | |
| 14678 | + | r0 = ret.Get(0).(bool) | |
| 14679 | + | } | |
| 14680 | + | return r0 | |
| 14681 | + | } | |
| 14682 | + | ||
| 14683 | + | // MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasActivePopupOpenerBridge' | |
| 14684 | + | type MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call struct { | |
| 14685 | + | *mock.Call | |
| 14686 | + | } | |
| 14687 | + | ||
| 14688 | + | // HasActivePopupOpenerBridge is a helper method to define mock.On call | |
| 14689 | + | func (_e *MockPopupOpenerCapable_Expecter) HasActivePopupOpenerBridge() *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call { | |
| 14690 | + | return &MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call{Call: _e.mock.On("HasActivePopupOpenerBridge")} | |
| 14691 | + | } | |
| 14692 | + | ||
| 14693 | + | func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) Run(run func()) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call { | |
| 14694 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 14695 | + | run() | |
| 14696 | + | }) | |
| 14697 | + | return _c | |
| 14698 | + | } | |
| 14699 | + | ||
| 14700 | + | func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) Return(b bool) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call { | |
| 14701 | + | _c.Call.Return(b) | |
| 14702 | + | return _c | |
| 14703 | + | } | |
| 14704 | + | ||
| 14705 | + | func (_c *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call) RunAndReturn(run func() bool) *MockPopupOpenerCapable_HasActivePopupOpenerBridge_Call { | |
| 14706 | + | _c.Call.Return(run) | |
| 14707 | + | return _c | |
| 14708 | + | } | |
| 14709 | + | ||
| 13441 | 14710 | // NewMockOAuthCallbackCapable creates a new instance of MockOAuthCallbackCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13442 | 14711 | // The first argument is typically a *testing.T value. | |
| 13443 | 14712 | func NewMockOAuthCallbackCapable(t interface { | |
| @@ -14102,6 +15371,59 @@ func (_c *MockXDGPaths_ManDir_Call) RunAndReturn(run func() (string, error)) *Mo | |||
| 14102 | 15371 | return _c | |
| 14103 | 15372 | } | |
| 14104 | 15373 | ||
| 15374 | + | // RuntimeDir provides a mock function for the type MockXDGPaths | |
| 15375 | + | func (_mock *MockXDGPaths) RuntimeDir() (string, error) { | |
| 15376 | + | ret := _mock.Called() | |
| 15377 | + | ||
| 15378 | + | if len(ret) == 0 { | |
| 15379 | + | panic("no return value specified for RuntimeDir") | |
| 15380 | + | } | |
| 15381 | + | ||
| 15382 | + | var r0 string | |
| 15383 | + | var r1 error | |
| 15384 | + | if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { | |
| 15385 | + | return returnFunc() | |
| 15386 | + | } | |
| 15387 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 15388 | + | r0 = returnFunc() | |
| 15389 | + | } else { | |
| 15390 | + | r0 = ret.Get(0).(string) | |
| 15391 | + | } | |
| 15392 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 15393 | + | r1 = returnFunc() | |
| 15394 | + | } else { | |
| 15395 | + | r1 = ret.Error(1) | |
| 15396 | + | } | |
| 15397 | + | return r0, r1 | |
| 15398 | + | } | |
| 15399 | + | ||
| 15400 | + | // MockXDGPaths_RuntimeDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RuntimeDir' | |
| 15401 | + | type MockXDGPaths_RuntimeDir_Call struct { | |
| 15402 | + | *mock.Call | |
| 15403 | + | } | |
| 15404 | + | ||
| 15405 | + | // RuntimeDir is a helper method to define mock.On call | |
| 15406 | + | func (_e *MockXDGPaths_Expecter) RuntimeDir() *MockXDGPaths_RuntimeDir_Call { | |
| 15407 | + | return &MockXDGPaths_RuntimeDir_Call{Call: _e.mock.On("RuntimeDir")} | |
| 15408 | + | } | |
| 15409 | + | ||
| 15410 | + | func (_c *MockXDGPaths_RuntimeDir_Call) Run(run func()) *MockXDGPaths_RuntimeDir_Call { | |
| 15411 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 15412 | + | run() | |
| 15413 | + | }) | |
| 15414 | + | return _c | |
| 15415 | + | } | |
| 15416 | + | ||
| 15417 | + | func (_c *MockXDGPaths_RuntimeDir_Call) Return(s string, err error) *MockXDGPaths_RuntimeDir_Call { | |
| 15418 | + | _c.Call.Return(s, err) | |
| 15419 | + | return _c | |
| 15420 | + | } | |
| 15421 | + | ||
| 15422 | + | func (_c *MockXDGPaths_RuntimeDir_Call) RunAndReturn(run func() (string, error)) *MockXDGPaths_RuntimeDir_Call { | |
| 15423 | + | _c.Call.Return(run) | |
| 15424 | + | return _c | |
| 15425 | + | } | |
| 15426 | + | ||
| 14105 | 15427 | // StateDir provides a mock function for the type MockXDGPaths | |
| 14106 | 15428 | func (_mock *MockXDGPaths) StateDir() (string, error) { | |
| 14107 | 15429 | ret := _mock.Called() | |
M internal/application/port/session_state.go +12 -0
| @@ -21,6 +21,18 @@ type SessionSpawner interface { | |||
| 21 | 21 | SpawnWithSession(sessionID entity.SessionID) error | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | // SessionSpawnEnvironment provides engine-specific environment overrides for | |
| 25 | + | // spawned restore-session processes. Implementations are optional: engines that | |
| 26 | + | // do not need extra launch environment can leave this nil. | |
| 27 | + | type SessionSpawnEnvironment interface { | |
| 28 | + | // RootCacheEnvVar returns the environment variable used to override the | |
| 29 | + | // engine's root cache/data directory. | |
| 30 | + | RootCacheEnvVar() string | |
| 31 | + | // SessionRootCachePath returns the engine-specific root cache/data path for | |
| 32 | + | // the provided restored session. | |
| 33 | + | SessionRootCachePath(sessionID entity.SessionID) string | |
| 34 | + | } | |
| 35 | + | ||
| 24 | 36 | // SnapshotService manages debounced session-state snapshots. | |
| 25 | 37 | type SnapshotService interface { | |
| 26 | 38 | // Start begins the snapshot service with the given context. | |
M internal/application/port/webview.go +24 -12
| @@ -3,9 +3,7 @@ | |||
| 3 | 3 | // remain independent of specific implementations (WebKit, GTK, etc.). | |
| 4 | 4 | package port | |
| 5 | 5 | ||
| 6 | - | import ( | |
| 7 | - | "context" | |
| 8 | - | ) | |
| 6 | + | import "context" | |
| 9 | 7 | ||
| 10 | 8 | // WebViewID uniquely identifies a WebView instance. | |
| 11 | 9 | type WebViewID uint64 | |
| @@ -64,10 +62,11 @@ const ( | |||
| 64 | 62 | ||
| 65 | 63 | // PopupRequest contains metadata about a popup window request. | |
| 66 | 64 | type PopupRequest struct { | |
| 67 | - | TargetURI string | |
| 68 | - | FrameName string // e.g., "_blank", custom name, or empty | |
| 69 | - | IsUserGesture bool | |
| 70 | - | ParentViewID WebViewID | |
| 65 | + | TargetURI string | |
| 66 | + | FrameName string // e.g., "_blank", custom name, or empty | |
| 67 | + | IsUserGesture bool | |
| 68 | + | NoJavaScriptAccess bool // true for noopener/noreferrer-style popups with no opener access | |
| 69 | + | ParentViewID WebViewID | |
| 71 | 70 | } | |
| 72 | 71 | ||
| 73 | 72 | // Texture represents a graphics texture returned by the engine. | |
| @@ -314,14 +313,27 @@ type Printer interface { | |||
| 314 | 313 | PrintPage() | |
| 315 | 314 | } | |
| 316 | 315 | ||
| 317 | - | // PopupCapable is implemented by WebViews that support popup lifecycle callbacks. | |
| 318 | - | // SetOnClose composes the provided function with any existing close handler so | |
| 319 | - | // that multiple callers can each register a close callback without overwriting | |
| 320 | - | // one another. | |
| 321 | - | type PopupCapable interface { | |
| 316 | + | // PopupLifecycleCapable is implemented by WebViews that support the full popup | |
| 317 | + | // pane lifecycle. SetOnClose composes the provided function with any existing | |
| 318 | + | // close handler so multiple callers can register close hooks without | |
| 319 | + | // overwriting one another. PrimePopupNavigation lets engines preserve the popup | |
| 320 | + | // target URI before the browser is fully created. | |
| 321 | + | type PopupLifecycleCapable interface { | |
| 322 | 322 | SetOnReadyToShow(fn func()) | |
| 323 | 323 | SetOnClose(fn func()) | |
| 324 | 324 | Show() | |
| 325 | + | PrimePopupNavigation(uri string) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | |
| 329 | + | // semantics when a popup must be created directly instead of through a native | |
| 330 | + | // related-popup lifecycle, and that can report or observe synthetic opener | |
| 331 | + | // traffic needed by OAuth flows. | |
| 332 | + | type PopupOpenerCapable interface { | |
| 333 | + | EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool) | |
| 334 | + | AddOpenerMessageCallback(fn func()) | |
| 335 | + | AddOpenerNavigationCallback(fn func(uri string)) | |
| 336 | + | HasActivePopupOpenerBridge() bool | |
| 325 | 337 | } | |
| 326 | 338 | ||
| 327 | 339 | // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks. | |
M internal/application/port/xdg.go +1 -0
| @@ -5,6 +5,7 @@ type XDGPaths interface { | |||
| 5 | 5 | ConfigDir() (string, error) | |
| 6 | 6 | DataDir() (string, error) | |
| 7 | 7 | StateDir() (string, error) | |
| 8 | + | RuntimeDir() (string, error) | |
| 8 | 9 | CacheDir() (string, error) | |
| 9 | 10 | ||
| 10 | 11 | FilterJSONDir() (string, error) | |
A internal/application/usecase/build_context_menu.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // BuildContextMenuUseCase normalizes context menu items from menu state. | |
| 10 | + | type BuildContextMenuUseCase struct{} | |
| 11 | + | ||
| 12 | + | var _ port.ContextMenuBuilder = (*BuildContextMenuUseCase)(nil) | |
| 13 | + | ||
| 14 | + | // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase. | |
| 15 | + | func NewBuildContextMenuUseCase() *BuildContextMenuUseCase { | |
| 16 | + | return &BuildContextMenuUseCase{} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Build returns normalized menu items for the supplied context. | |
| 20 | + | func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem { | |
| 21 | + | if menuContext.IsEditable { | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | items := make([]port.MenuItem, 0, 9) | |
| 26 | + | ||
| 27 | + | if menuContext.CanGoBack { | |
| 28 | + | items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"}) | |
| 29 | + | } | |
| 30 | + | if menuContext.CanGoForward { | |
| 31 | + | items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"}) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}) | |
| 35 | + | ||
| 36 | + | if menuContext.LinkURI != "" { | |
| 37 | + | items = append(items, | |
| 38 | + | port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"}, | |
| 39 | + | port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"}, | |
| 40 | + | ) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | if menuContext.ImageURI != "" { | |
| 44 | + | items = append(items, | |
| 45 | + | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 46 | + | port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"}, | |
| 47 | + | ) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | if menuContext.HasSelection { | |
| 51 | + | items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"}) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |
| 55 | + | ||
| 56 | + | return items | |
| 57 | + | } | |
A internal/application/usecase/build_context_menu_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBuildContextMenuUseCase_Build(t *testing.T) { | |
| 12 | + | uc := NewBuildContextMenuUseCase() | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | context port.MenuContext | |
| 17 | + | expected []port.MenuAction | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "editable context uses native menu", | |
| 21 | + | context: port.MenuContext{IsEditable: true}, | |
| 22 | + | expected: []port.MenuAction{}, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "empty context baseline", | |
| 26 | + | context: port.MenuContext{}, | |
| 27 | + | expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement}, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "selection context", | |
| 31 | + | context: port.MenuContext{HasSelection: true}, | |
| 32 | + | expected: []port.MenuAction{ | |
| 33 | + | port.MenuActionReload, | |
| 34 | + | port.MenuActionCopySelection, | |
| 35 | + | port.MenuActionInspectElement, | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "image only", | |
| 40 | + | context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 41 | + | expected: []port.MenuAction{ | |
| 42 | + | port.MenuActionReload, | |
| 43 | + | port.MenuActionCopyImage, | |
| 44 | + | port.MenuActionSaveImage, | |
| 45 | + | port.MenuActionInspectElement, | |
| 46 | + | }, | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "link only", | |
| 50 | + | context: port.MenuContext{LinkURI: "https://example.com/link"}, | |
| 51 | + | expected: []port.MenuAction{ | |
| 52 | + | port.MenuActionReload, | |
| 53 | + | port.MenuActionOpenLinkNewTab, | |
| 54 | + | port.MenuActionCopyLink, | |
| 55 | + | port.MenuActionInspectElement, | |
| 56 | + | }, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "navigation enabled states", | |
| 60 | + | context: port.MenuContext{CanGoBack: true, CanGoForward: true}, | |
| 61 | + | expected: []port.MenuAction{ | |
| 62 | + | port.MenuActionBack, | |
| 63 | + | port.MenuActionForward, | |
| 64 | + | port.MenuActionReload, | |
| 65 | + | port.MenuActionInspectElement, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | items := uc.Build(context.Background(), tt.context) | |
| 73 | + | require.Equal(t, tt.expected, menuActions(items)) | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func menuActions(items []port.MenuItem) []port.MenuAction { | |
| 79 | + | actions := make([]port.MenuAction, 0, len(items)) | |
| 80 | + | for _, item := range items { | |
| 81 | + | actions = append(actions, item.Action) | |
| 82 | + | } | |
| 83 | + | return actions | |
| 84 | + | } | |
A internal/application/usecase/clipboard_text_orchestrator.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const explicitClipboardDedupWindow = 250 * time.Millisecond | |
| 17 | + | ||
| 18 | + | // ClipboardTextOrchestratorUseCase applies shared clipboard business rules. | |
| 19 | + | type ClipboardTextOrchestratorUseCase struct { | |
| 20 | + | clipboard port.Clipboard | |
| 21 | + | autoCopyConfig port.AutoCopyConfig | |
| 22 | + | toast func(textLen int) | |
| 23 | + | mu sync.Mutex | |
| 24 | + | lastSelection map[clipboardScope]string | |
| 25 | + | lastExplicit map[clipboardScope]explicitCopyState | |
| 26 | + | now func() time.Time | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type clipboardScope struct { | |
| 30 | + | source dto.ClipboardSource | |
| 31 | + | viewID uint64 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type explicitCopyState struct { | |
| 35 | + | text string | |
| 36 | + | action string | |
| 37 | + | at time.Time | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil) | |
| 41 | + | ||
| 42 | + | // NewClipboardTextOrchestrator creates a shared clipboard orchestrator. | |
| 43 | + | func NewClipboardTextOrchestrator( | |
| 44 | + | clipboard port.Clipboard, | |
| 45 | + | autoCopyConfig port.AutoCopyConfig, | |
| 46 | + | toast func(textLen int), | |
| 47 | + | ) *ClipboardTextOrchestratorUseCase { | |
| 48 | + | return &ClipboardTextOrchestratorUseCase{ | |
| 49 | + | clipboard: clipboard, | |
| 50 | + | autoCopyConfig: autoCopyConfig, | |
| 51 | + | toast: toast, | |
| 52 | + | lastSelection: make(map[clipboardScope]string), | |
| 53 | + | lastExplicit: make(map[clipboardScope]explicitCopyState), | |
| 54 | + | now: time.Now, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // HandleSelectionUpdate applies auto-copy selection rules. | |
| 59 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |
| 60 | + | ctx context.Context, | |
| 61 | + | input dto.SelectionClipboardInput, | |
| 62 | + | ) error { | |
| 63 | + | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | |
| 64 | + | if uc != nil { | |
| 65 | + | uc.mu.Lock() | |
| 66 | + | delete(uc.lastSelection, selectionScope(input)) | |
| 67 | + | uc.mu.Unlock() | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | if input.Text == "" { | |
| 73 | + | uc.mu.Lock() | |
| 74 | + | delete(uc.lastSelection, selectionScope(input)) | |
| 75 | + | uc.mu.Unlock() | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | textLen := utf8.RuneCountInString(input.Text) | |
| 83 | + | scope := selectionScope(input) | |
| 84 | + | currentTime := uc.currentTime() | |
| 85 | + | ||
| 86 | + | uc.mu.Lock() | |
| 87 | + | if input.Text == uc.lastSelection[scope] { | |
| 88 | + | uc.mu.Unlock() | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | if uc.clipboard == nil { | |
| 92 | + | uc.mu.Unlock() | |
| 93 | + | return fmt.Errorf("clipboard not available") | |
| 94 | + | } | |
| 95 | + | selectionState := input.Text | |
| 96 | + | explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"} | |
| 97 | + | explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime} | |
| 98 | + | uc.lastSelection[scope] = selectionState | |
| 99 | + | uc.lastExplicit[explicitScope(explicitInput)] = explicitState | |
| 100 | + | uc.mu.Unlock() | |
| 101 | + | ||
| 102 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |
| 103 | + | uc.mu.Lock() | |
| 104 | + | if uc.lastSelection[scope] == selectionState { | |
| 105 | + | delete(uc.lastSelection, scope) | |
| 106 | + | } | |
| 107 | + | if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState { | |
| 108 | + | delete(uc.lastExplicit, explicitScope(explicitInput)) | |
| 109 | + | } | |
| 110 | + | uc.mu.Unlock() | |
| 111 | + | return fmt.Errorf("clipboard write failed: %w", err) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | if uc.toast != nil { | |
| 115 | + | uc.toast(textLen) | |
| 116 | + | } | |
| 117 | + | logging.FromContext(ctx).Debug(). | |
| 118 | + | Int("text_len", textLen). | |
| 119 | + | Str("source_engine", string(input.SourceEngine)). | |
| 120 | + | Msg("clipboard selection copied") | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // HandleExplicitCopy applies explicit copy rules. | |
| 125 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |
| 126 | + | ctx context.Context, | |
| 127 | + | input dto.ExplicitClipboardInput, | |
| 128 | + | ) error { | |
| 129 | + | if uc == nil || input.Text == "" { | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | currentTime := uc.currentTime() | |
| 134 | + | textLen := utf8.RuneCountInString(input.Text) | |
| 135 | + | scope := explicitScope(input) | |
| 136 | + | ||
| 137 | + | state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime} | |
| 138 | + | ||
| 139 | + | uc.mu.Lock() | |
| 140 | + | if previous, ok := uc.lastExplicit[scope]; ok && | |
| 141 | + | input.Text == previous.text && | |
| 142 | + | input.Action == previous.action && | |
| 143 | + | currentTime.Sub(previous.at) < explicitClipboardDedupWindow { | |
| 144 | + | uc.mu.Unlock() | |
| 145 | + | return nil | |
| 146 | + | } | |
| 147 | + | if !input.NativeHandled && uc.clipboard == nil { | |
| 148 | + | uc.mu.Unlock() | |
| 149 | + | return fmt.Errorf("clipboard not available") | |
| 150 | + | } | |
| 151 | + | uc.lastExplicit[scope] = state | |
| 152 | + | uc.mu.Unlock() | |
| 153 | + | ||
| 154 | + | if !input.NativeHandled { | |
| 155 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |
| 156 | + | uc.mu.Lock() | |
| 157 | + | if current, ok := uc.lastExplicit[scope]; ok && current == state { | |
| 158 | + | delete(uc.lastExplicit, scope) | |
| 159 | + | } | |
| 160 | + | uc.mu.Unlock() | |
| 161 | + | return fmt.Errorf("clipboard write failed: %w", err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | if uc.toast != nil { | |
| 166 | + | uc.toast(textLen) | |
| 167 | + | } | |
| 168 | + | logging.FromContext(ctx).Debug(). | |
| 169 | + | Int("text_len", textLen). | |
| 170 | + | Str("source_engine", string(input.SourceEngine)). | |
| 171 | + | Str("action", input.Action). | |
| 172 | + | Bool("native_handled", input.NativeHandled). | |
| 173 | + | Msg("clipboard explicit copied") | |
| 174 | + | return nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func selectionScope(input dto.SelectionClipboardInput) clipboardScope { | |
| 178 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func explicitScope(input dto.ExplicitClipboardInput) clipboardScope { | |
| 182 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time { | |
| 186 | + | if uc == nil || uc.now == nil { | |
| 187 | + | return time.Now() | |
| 188 | + | } | |
| 189 | + | return uc.now() | |
| 190 | + | } | |
A internal/application/usecase/clipboard_text_orchestrator_test.go +473 -0
| @@ -0,0 +1,473 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type blockingClipboard struct { | |
| 16 | + | started chan struct{} | |
| 17 | + | release chan struct{} | |
| 18 | + | mu sync.Mutex | |
| 19 | + | writes []string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type countingClipboard struct { | |
| 23 | + | mu sync.Mutex | |
| 24 | + | writes int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type staticAutoCopyConfig struct { | |
| 28 | + | enabled bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled } | |
| 32 | + | ||
| 33 | + | func newBlockingClipboard() *blockingClipboard { | |
| 34 | + | return &blockingClipboard{ | |
| 35 | + | started: make(chan struct{}, 2), | |
| 36 | + | release: make(chan struct{}), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (b *blockingClipboard) WriteText(_ context.Context, text string) error { | |
| 41 | + | b.mu.Lock() | |
| 42 | + | b.writes = append(b.writes, text) | |
| 43 | + | b.mu.Unlock() | |
| 44 | + | b.started <- struct{}{} | |
| 45 | + | <-b.release | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |
| 50 | + | func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 51 | + | func (*blockingClipboard) Clear(context.Context) error { return nil } | |
| 52 | + | func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 53 | + | ||
| 54 | + | func (b *blockingClipboard) writeCount() int { | |
| 55 | + | b.mu.Lock() | |
| 56 | + | defer b.mu.Unlock() | |
| 57 | + | return len(b.writes) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (c *countingClipboard) WriteText(_ context.Context, _ string) error { | |
| 61 | + | c.mu.Lock() | |
| 62 | + | c.writes++ | |
| 63 | + | c.mu.Unlock() | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |
| 68 | + | func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 69 | + | func (*countingClipboard) Clear(context.Context) error { return nil } | |
| 70 | + | func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 71 | + | ||
| 72 | + | func (c *countingClipboard) writeCount() int { | |
| 73 | + | c.mu.Lock() | |
| 74 | + | defer c.mu.Unlock() | |
| 75 | + | return c.writes | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) { | |
| 79 | + | ctx := context.Background() | |
| 80 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 81 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: false} | |
| 82 | + | var toastCalls []int | |
| 83 | + | ||
| 84 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 85 | + | toastCalls = append(toastCalls, textLen) | |
| 86 | + | }) | |
| 87 | + | ||
| 88 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 89 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | if len(toastCalls) != 0 { | |
| 93 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) { | |
| 98 | + | ctx := context.Background() | |
| 99 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 100 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 101 | + | var toastCalls []int | |
| 102 | + | ||
| 103 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |
| 104 | + | ||
| 105 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 106 | + | toastCalls = append(toastCalls, textLen) | |
| 107 | + | }) | |
| 108 | + | ||
| 109 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 110 | + | t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err) | |
| 111 | + | } | |
| 112 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 113 | + | t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err) | |
| 114 | + | } | |
| 115 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 116 | + | t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err) | |
| 117 | + | } | |
| 118 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 119 | + | t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err) | |
| 120 | + | } | |
| 121 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 122 | + | t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |
| 126 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) { | |
| 131 | + | ctx := context.Background() | |
| 132 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 133 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 134 | + | ||
| 135 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 136 | + | ||
| 137 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 138 | + | ||
| 139 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil { | |
| 140 | + | t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err) | |
| 141 | + | } | |
| 142 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |
| 143 | + | t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) { | |
| 148 | + | ctx := context.Background() | |
| 149 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 150 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 151 | + | ||
| 152 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 153 | + | ||
| 154 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 155 | + | ||
| 156 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 157 | + | t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err) | |
| 158 | + | } | |
| 159 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil { | |
| 160 | + | t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) { | |
| 165 | + | ctx := context.Background() | |
| 166 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 167 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 168 | + | var toastCalls []int | |
| 169 | + | ||
| 170 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |
| 171 | + | ||
| 172 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 173 | + | toastCalls = append(toastCalls, textLen) | |
| 174 | + | }) | |
| 175 | + | ||
| 176 | + | now := time.Unix(0, 0) | |
| 177 | + | uc.now = func() time.Time { return now } | |
| 178 | + | ||
| 179 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 180 | + | t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err) | |
| 181 | + | } | |
| 182 | + | now = now.Add(100 * time.Millisecond) | |
| 183 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 184 | + | t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err) | |
| 185 | + | } | |
| 186 | + | now = now.Add(151 * time.Millisecond) | |
| 187 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 188 | + | t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |
| 192 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) { | |
| 197 | + | ctx := context.Background() | |
| 198 | + | clipboard := &countingClipboard{} | |
| 199 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 200 | + | var toastCalls []int | |
| 201 | + | ||
| 202 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 203 | + | toastCalls = append(toastCalls, textLen) | |
| 204 | + | }) | |
| 205 | + | ||
| 206 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil { | |
| 207 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | if got := clipboard.writeCount(); got != 0 { | |
| 211 | + | t.Fatalf("clipboard writes = %d, want 0", got) | |
| 212 | + | } | |
| 213 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |
| 214 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |
| 215 | + | } | |
| 216 | + | if got := len(uc.lastExplicit); got != 1 { | |
| 217 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) { | |
| 222 | + | ctx := context.Background() | |
| 223 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 224 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 225 | + | ||
| 226 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 227 | + | ||
| 228 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 229 | + | now := time.Unix(0, 0) | |
| 230 | + | uc.now = func() time.Time { return now } | |
| 231 | + | ||
| 232 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |
| 233 | + | t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err) | |
| 234 | + | } | |
| 235 | + | now = now.Add(100 * time.Millisecond) | |
| 236 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil { | |
| 237 | + | t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) { | |
| 242 | + | ctx := context.Background() | |
| 243 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 244 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 245 | + | var toastCalls []int | |
| 246 | + | ||
| 247 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once() | |
| 248 | + | ||
| 249 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 250 | + | toastCalls = append(toastCalls, textLen) | |
| 251 | + | }) | |
| 252 | + | ||
| 253 | + | now := time.Unix(0, 0) | |
| 254 | + | uc.now = func() time.Time { return now } | |
| 255 | + | ||
| 256 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 257 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |
| 258 | + | } | |
| 259 | + | now = now.Add(100 * time.Millisecond) | |
| 260 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |
| 261 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |
| 265 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |
| 266 | + | } | |
| 267 | + | if got := len(uc.lastExplicit); got != 1 { | |
| 268 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) { | |
| 273 | + | ctx := context.Background() | |
| 274 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 275 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 276 | + | ||
| 277 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 278 | + | ||
| 279 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 280 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 281 | + | ||
| 282 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 283 | + | t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err) | |
| 284 | + | } | |
| 285 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil { | |
| 286 | + | t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) { | |
| 291 | + | ctx := context.Background() | |
| 292 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 293 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 294 | + | ||
| 295 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 296 | + | ||
| 297 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 298 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 299 | + | ||
| 300 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |
| 301 | + | t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err) | |
| 302 | + | } | |
| 303 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil { | |
| 304 | + | t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) { | |
| 309 | + | ctx := context.Background() | |
| 310 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 311 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 312 | + | ||
| 313 | + | clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once() | |
| 314 | + | clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once() | |
| 315 | + | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | |
| 316 | + | ||
| 317 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 318 | + | ||
| 319 | + | for i, text := range []string{"one", "two", "three"} { | |
| 320 | + | advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second) | |
| 321 | + | uc.now = func() time.Time { return advancedTime } | |
| 322 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | |
| 323 | + | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | if got := len(uc.lastExplicit); got != 1 { | |
| 328 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) { | |
| 333 | + | ctx := context.Background() | |
| 334 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 335 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 336 | + | ||
| 337 | + | clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2) | |
| 338 | + | ||
| 339 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 340 | + | ||
| 341 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 342 | + | t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err) | |
| 343 | + | } | |
| 344 | + | autoCopyConfig.enabled = false | |
| 345 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 346 | + | t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err) | |
| 347 | + | } | |
| 348 | + | autoCopyConfig.enabled = true | |
| 349 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 350 | + | t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) { | |
| 355 | + | ctx := context.Background() | |
| 356 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 357 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once() | |
| 358 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 359 | + | ||
| 360 | + | var toastCalls []int | |
| 361 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 362 | + | toastCalls = append(toastCalls, textLen) | |
| 363 | + | }) | |
| 364 | + | ||
| 365 | + | err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}) | |
| 366 | + | if err == nil { | |
| 367 | + | t.Fatal("HandleExplicitCopy() error = nil, want non-nil") | |
| 368 | + | } | |
| 369 | + | if len(toastCalls) != 0 { | |
| 370 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) { | |
| 375 | + | ctx := context.Background() | |
| 376 | + | clipboard := newBlockingClipboard() | |
| 377 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 378 | + | var ( | |
| 379 | + | toastCalls []int | |
| 380 | + | toastMu sync.Mutex | |
| 381 | + | ) | |
| 382 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 383 | + | toastMu.Lock() | |
| 384 | + | defer toastMu.Unlock() | |
| 385 | + | toastCalls = append(toastCalls, textLen) | |
| 386 | + | }) | |
| 387 | + | ||
| 388 | + | done1 := make(chan error, 1) | |
| 389 | + | go func() { | |
| 390 | + | done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}) | |
| 391 | + | }() | |
| 392 | + | <-clipboard.started | |
| 393 | + | ||
| 394 | + | done2 := make(chan error, 1) | |
| 395 | + | go func() { | |
| 396 | + | done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}) | |
| 397 | + | }() | |
| 398 | + | ||
| 399 | + | select { | |
| 400 | + | case <-clipboard.started: | |
| 401 | + | t.Fatal("second selection write started before first completed") | |
| 402 | + | default: | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | close(clipboard.release) | |
| 406 | + | ||
| 407 | + | if err := <-done1; err != nil { | |
| 408 | + | t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err) | |
| 409 | + | } | |
| 410 | + | if err := <-done2; err != nil { | |
| 411 | + | t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | if got := clipboard.writeCount(); got != 1 { | |
| 415 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 416 | + | } | |
| 417 | + | toastMu.Lock() | |
| 418 | + | defer toastMu.Unlock() | |
| 419 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 420 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) { | |
| 425 | + | ctx := context.Background() | |
| 426 | + | clipboard := newBlockingClipboard() | |
| 427 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 428 | + | var ( | |
| 429 | + | toastCalls []int | |
| 430 | + | toastMu sync.Mutex | |
| 431 | + | ) | |
| 432 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 433 | + | toastMu.Lock() | |
| 434 | + | defer toastMu.Unlock() | |
| 435 | + | toastCalls = append(toastCalls, textLen) | |
| 436 | + | }) | |
| 437 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 438 | + | ||
| 439 | + | done1 := make(chan error, 1) | |
| 440 | + | go func() { | |
| 441 | + | done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}) | |
| 442 | + | }() | |
| 443 | + | <-clipboard.started | |
| 444 | + | ||
| 445 | + | done2 := make(chan error, 1) | |
| 446 | + | go func() { | |
| 447 | + | done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}) | |
| 448 | + | }() | |
| 449 | + | ||
| 450 | + | select { | |
| 451 | + | case <-clipboard.started: | |
| 452 | + | t.Fatal("second explicit write started before first completed") | |
| 453 | + | default: | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | close(clipboard.release) | |
| 457 | + | ||
| 458 | + | if err := <-done1; err != nil { | |
| 459 | + | t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err) | |
| 460 | + | } | |
| 461 | + | if err := <-done2; err != nil { | |
| 462 | + | t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err) | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | if got := clipboard.writeCount(); got != 1 { | |
| 466 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 467 | + | } | |
| 468 | + | toastMu.Lock() | |
| 469 | + | defer toastMu.Unlock() | |
| 470 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 471 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 472 | + | } | |
| 473 | + | } | |
A internal/application/usecase/execute_context_menu_action.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ExecuteContextMenuActionInput identifies the action and its context. | |
| 12 | + | type ExecuteContextMenuActionInput struct { | |
| 13 | + | Action port.MenuAction | |
| 14 | + | Context port.MenuContext | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // ExecuteContextMenuActionUseCase handles shared context menu actions. | |
| 18 | + | type ExecuteContextMenuActionUseCase struct { | |
| 19 | + | clipboard port.Clipboard | |
| 20 | + | resolver port.ImageDataResolver | |
| 21 | + | saver port.ResolvedImageSaver | |
| 22 | + | delegator port.MenuActionDelegator | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | var _ port.ContextMenuActionExecutor = (*ExecuteContextMenuActionUseCase)(nil) | |
| 26 | + | ||
| 27 | + | // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase. | |
| 28 | + | func NewExecuteContextMenuActionUseCase( | |
| 29 | + | clipboard port.Clipboard, | |
| 30 | + | resolver port.ImageDataResolver, | |
| 31 | + | saver port.ResolvedImageSaver, | |
| 32 | + | delegator port.MenuActionDelegator, | |
| 33 | + | ) *ExecuteContextMenuActionUseCase { | |
| 34 | + | return &ExecuteContextMenuActionUseCase{ | |
| 35 | + | clipboard: clipboard, | |
| 36 | + | resolver: resolver, | |
| 37 | + | saver: saver, | |
| 38 | + | delegator: delegator, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ContextMenuActionExecutorFactory creates ExecuteContextMenuActionUseCases. | |
| 43 | + | type ContextMenuActionExecutorFactory struct{} | |
| 44 | + | ||
| 45 | + | var _ port.ContextMenuActionExecutorFactory = (*ContextMenuActionExecutorFactory)(nil) | |
| 46 | + | ||
| 47 | + | // NewContextMenuActionExecutor creates a new shared context menu action executor. | |
| 48 | + | func (*ContextMenuActionExecutorFactory) NewContextMenuActionExecutor( | |
| 49 | + | clipboard port.Clipboard, | |
| 50 | + | resolver port.ImageDataResolver, | |
| 51 | + | saver port.ResolvedImageSaver, | |
| 52 | + | delegator port.MenuActionDelegator, | |
| 53 | + | ) port.ContextMenuActionExecutor { | |
| 54 | + | return NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Execute handles the requested action. | |
| 58 | + | func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error { | |
| 59 | + | return uc.ExecuteMenuAction(ctx, input.Action, input.Context) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ExecuteMenuAction handles the requested action. | |
| 63 | + | func (uc *ExecuteContextMenuActionUseCase) ExecuteMenuAction( | |
| 64 | + | ctx context.Context, action port.MenuAction, menuContext port.MenuContext, | |
| 65 | + | ) error { | |
| 66 | + | return uc.executeMenuAction(ctx, action, menuContext) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |
| 70 | + | ctx context.Context, action port.MenuAction, menuContext port.MenuContext, | |
| 71 | + | ) error { | |
| 72 | + | switch action { | |
| 73 | + | case port.MenuActionCopyLink: | |
| 74 | + | if menuContext.LinkURI == "" { | |
| 75 | + | return fmt.Errorf("copy link: link URI not available") | |
| 76 | + | } | |
| 77 | + | if uc.clipboard == nil { | |
| 78 | + | return fmt.Errorf("copy link: clipboard not available") | |
| 79 | + | } | |
| 80 | + | if err := uc.clipboard.WriteText(ctx, menuContext.LinkURI); err != nil { | |
| 81 | + | return fmt.Errorf("copy link: %w", err) | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | case port.MenuActionCopyImage: | |
| 85 | + | if uc.clipboard == nil { | |
| 86 | + | return fmt.Errorf("copy image: clipboard not available") | |
| 87 | + | } | |
| 88 | + | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 89 | + | if err != nil { | |
| 90 | + | return fmt.Errorf("copy image: %w", err) | |
| 91 | + | } | |
| 92 | + | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | |
| 93 | + | return fmt.Errorf("copy image: %w", err) | |
| 94 | + | } | |
| 95 | + | return nil | |
| 96 | + | case port.MenuActionSaveImage: | |
| 97 | + | if uc.saver == nil { | |
| 98 | + | return fmt.Errorf("save image: image saver not available") | |
| 99 | + | } | |
| 100 | + | image, err := uc.resolveImageData(ctx, menuContext.ImageURI) | |
| 101 | + | if err != nil { | |
| 102 | + | return fmt.Errorf("save image: %w", err) | |
| 103 | + | } | |
| 104 | + | if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil { | |
| 105 | + | return fmt.Errorf("save image: %w", err) | |
| 106 | + | } | |
| 107 | + | return nil | |
| 108 | + | case port.MenuActionCopySelection: | |
| 109 | + | // When selection text is present and clipboard wiring is available, copy it here. | |
| 110 | + | // Otherwise intentionally fall through to DelegateMenuAction so native browser copy | |
| 111 | + | // behavior can handle document selections that are only accessible inside the engine. | |
| 112 | + | if menuContext.SelectionText != "" && uc.clipboard != nil { | |
| 113 | + | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | |
| 114 | + | return fmt.Errorf("copy selection: %w", err) | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // All other normalized actions are delegated to the engine/UI layer. | |
| 121 | + | if uc.delegator == nil { | |
| 122 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |
| 123 | + | } | |
| 124 | + | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |
| 125 | + | return fmt.Errorf("delegate action %s: %w", action, err) | |
| 126 | + | } | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | |
| 131 | + | if uri == "" { | |
| 132 | + | return entity.ImageData{}, fmt.Errorf("image URI not available") | |
| 133 | + | } | |
| 134 | + | if uc.resolver == nil { | |
| 135 | + | return entity.ImageData{}, fmt.Errorf("image data resolver not available") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | image, err := uc.resolver.ResolveImageData(ctx, uri) | |
| 139 | + | if err != nil { | |
| 140 | + | return entity.ImageData{}, err | |
| 141 | + | } | |
| 142 | + | if len(image.Bytes) == 0 { | |
| 143 | + | return entity.ImageData{}, fmt.Errorf("image data not available") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | return image, nil | |
| 147 | + | } | |
A internal/application/usecase/execute_context_menu_action_test.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type fakeClipboard struct { | |
| 14 | + | writeTextCalls int | |
| 15 | + | writeImageCalls int | |
| 16 | + | text string | |
| 17 | + | image entity.ImageData | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |
| 21 | + | f.writeTextCalls++ | |
| 22 | + | f.text = text | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error { | |
| 27 | + | f.writeImageCalls++ | |
| 28 | + | f.image = image | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 33 | + | func (*fakeClipboard) Clear(context.Context) error { return nil } | |
| 34 | + | func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 35 | + | ||
| 36 | + | type fakeImageResolver struct { | |
| 37 | + | resolveCalls int | |
| 38 | + | uri string | |
| 39 | + | image entity.ImageData | |
| 40 | + | err error | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) { | |
| 44 | + | f.resolveCalls++ | |
| 45 | + | f.uri = uri | |
| 46 | + | return f.image, f.err | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type fakeResolvedImageSaver struct { | |
| 50 | + | saveCalls int | |
| 51 | + | image entity.ImageData | |
| 52 | + | menuContext port.MenuContext | |
| 53 | + | err error | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 57 | + | f.saveCalls++ | |
| 58 | + | f.image = image | |
| 59 | + | f.menuContext = menuContext | |
| 60 | + | return f.err | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type fakeMenuActionDelegator struct { | |
| 64 | + | delegateCalls int | |
| 65 | + | action port.MenuAction | |
| 66 | + | menuContext port.MenuContext | |
| 67 | + | err error | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 71 | + | f.delegateCalls++ | |
| 72 | + | f.action = action | |
| 73 | + | f.menuContext = menuContext | |
| 74 | + | return f.err | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) { | |
| 78 | + | clipboard := &fakeClipboard{} | |
| 79 | + | resolver := &fakeImageResolver{} | |
| 80 | + | saver := &fakeResolvedImageSaver{} | |
| 81 | + | delegator := &fakeMenuActionDelegator{} | |
| 82 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 83 | + | ||
| 84 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 85 | + | Action: port.MenuActionCopyImage, | |
| 86 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 87 | + | }) | |
| 88 | + | ||
| 89 | + | require.Error(t, err) | |
| 90 | + | require.Contains(t, err.Error(), "copy image:") | |
| 91 | + | require.Contains(t, err.Error(), "image data not available") | |
| 92 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 93 | + | require.Zero(t, saver.saveCalls) | |
| 94 | + | require.Zero(t, delegator.delegateCalls) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) { | |
| 98 | + | clipboard := &fakeClipboard{} | |
| 99 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |
| 100 | + | saver := &fakeResolvedImageSaver{} | |
| 101 | + | delegator := &fakeMenuActionDelegator{} | |
| 102 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 103 | + | ||
| 104 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 105 | + | Action: port.MenuActionCopyImage, | |
| 106 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 107 | + | }) | |
| 108 | + | ||
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.Equal(t, 1, resolver.resolveCalls) | |
| 111 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 112 | + | require.Equal(t, 1, clipboard.writeImageCalls) | |
| 113 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image) | |
| 114 | + | require.Zero(t, saver.saveCalls) | |
| 115 | + | require.Zero(t, delegator.delegateCalls) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) { | |
| 119 | + | resolver := &fakeImageResolver{} | |
| 120 | + | saver := &fakeResolvedImageSaver{} | |
| 121 | + | delegator := &fakeMenuActionDelegator{} | |
| 122 | + | uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator) | |
| 123 | + | ||
| 124 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 125 | + | Action: port.MenuActionCopyImage, | |
| 126 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | require.Error(t, err) | |
| 130 | + | require.Contains(t, err.Error(), "clipboard not available") | |
| 131 | + | require.Zero(t, resolver.resolveCalls) | |
| 132 | + | require.Zero(t, saver.saveCalls) | |
| 133 | + | require.Zero(t, delegator.delegateCalls) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | |
| 137 | + | clipboard := &fakeClipboard{} | |
| 138 | + | resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}} | |
| 139 | + | saver := &fakeResolvedImageSaver{} | |
| 140 | + | delegator := &fakeMenuActionDelegator{} | |
| 141 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 142 | + | ||
| 143 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 144 | + | Action: port.MenuActionSaveImage, | |
| 145 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 146 | + | }) | |
| 147 | + | ||
| 148 | + | require.NoError(t, err) | |
| 149 | + | require.Equal(t, 1, resolver.resolveCalls) | |
| 150 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 151 | + | require.Equal(t, 1, saver.saveCalls) | |
| 152 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image) | |
| 153 | + | require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext) | |
| 154 | + | require.Zero(t, clipboard.writeTextCalls) | |
| 155 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 156 | + | require.Zero(t, delegator.delegateCalls) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) { | |
| 160 | + | clipboard := &fakeClipboard{} | |
| 161 | + | resolver := &fakeImageResolver{} | |
| 162 | + | delegator := &fakeMenuActionDelegator{} | |
| 163 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator) | |
| 164 | + | ||
| 165 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 166 | + | Action: port.MenuActionSaveImage, | |
| 167 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | require.Error(t, err) | |
| 171 | + | require.Contains(t, err.Error(), "image saver not available") | |
| 172 | + | require.Zero(t, resolver.resolveCalls) | |
| 173 | + | require.Zero(t, delegator.delegateCalls) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | |
| 177 | + | clipboard := &fakeClipboard{} | |
| 178 | + | resolver := &fakeImageResolver{} | |
| 179 | + | saver := &fakeResolvedImageSaver{} | |
| 180 | + | delegator := &fakeMenuActionDelegator{} | |
| 181 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 182 | + | ||
| 183 | + | menuContext := port.MenuContext{ | |
| 184 | + | PageURI: "https://example.com", | |
| 185 | + | X: 17, | |
| 186 | + | Y: 42, | |
| 187 | + | } | |
| 188 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 189 | + | Action: port.MenuActionInspectElement, | |
| 190 | + | Context: menuContext, | |
| 191 | + | }) | |
| 192 | + | ||
| 193 | + | require.NoError(t, err) | |
| 194 | + | require.Equal(t, 1, delegator.delegateCalls) | |
| 195 | + | require.Equal(t, port.MenuActionInspectElement, delegator.action) | |
| 196 | + | require.Equal(t, menuContext, delegator.menuContext) | |
| 197 | + | require.Zero(t, clipboard.writeTextCalls) | |
| 198 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 199 | + | require.Zero(t, resolver.resolveCalls) | |
| 200 | + | require.Zero(t, saver.saveCalls) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |
| 204 | + | clipboard := &fakeClipboard{} | |
| 205 | + | resolver := &fakeImageResolver{} | |
| 206 | + | saver := &fakeResolvedImageSaver{} | |
| 207 | + | delegator := &fakeMenuActionDelegator{err: errors.New("boom")} | |
| 208 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 209 | + | ||
| 210 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 211 | + | Action: port.MenuActionCopySelection, | |
| 212 | + | Context: port.MenuContext{HasSelection: true}, | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | require.Error(t, err) | |
| 216 | + | require.Contains(t, err.Error(), "delegate action copy_selection:") | |
| 217 | + | require.Contains(t, err.Error(), "boom") | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *testing.T) { | |
| 221 | + | clipboard := &fakeClipboard{} | |
| 222 | + | resolver := &fakeImageResolver{} | |
| 223 | + | saver := &fakeResolvedImageSaver{} | |
| 224 | + | delegator := &fakeMenuActionDelegator{} | |
| 225 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 226 | + | ||
| 227 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 228 | + | Action: port.MenuActionCopySelection, | |
| 229 | + | Context: port.MenuContext{ | |
| 230 | + | HasSelection: true, | |
| 231 | + | SelectionText: "selected text", | |
| 232 | + | }, | |
| 233 | + | }) | |
| 234 | + | ||
| 235 | + | require.NoError(t, err) | |
| 236 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |
| 237 | + | require.Equal(t, "selected text", clipboard.text) | |
| 238 | + | require.Zero(t, delegator.delegateCalls) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestExecuteContextMenuActionUseCase_CopySelectionFallsBackToDelegatorWhenClipboardMissing(t *testing.T) { | |
| 242 | + | resolver := &fakeImageResolver{} | |
| 243 | + | saver := &fakeResolvedImageSaver{} | |
| 244 | + | delegator := &fakeMenuActionDelegator{} | |
| 245 | + | uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator) | |
| 246 | + | ||
| 247 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 248 | + | Action: port.MenuActionCopySelection, | |
| 249 | + | Context: port.MenuContext{ | |
| 250 | + | HasSelection: true, | |
| 251 | + | SelectionText: "selected text", | |
| 252 | + | }, | |
| 253 | + | }) | |
| 254 | + | ||
| 255 | + | require.NoError(t, err) | |
| 256 | + | require.Equal(t, 1, delegator.delegateCalls) | |
| 257 | + | require.Equal(t, port.MenuActionCopySelection, delegator.action) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | |
| 261 | + | clipboard := &fakeClipboard{} | |
| 262 | + | resolver := &fakeImageResolver{} | |
| 263 | + | saver := &fakeResolvedImageSaver{} | |
| 264 | + | delegator := &fakeMenuActionDelegator{} | |
| 265 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 266 | + | ||
| 267 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 268 | + | Action: port.MenuActionCopyLink, | |
| 269 | + | Context: port.MenuContext{LinkURI: "https://example.com/link"}, | |
| 270 | + | }) | |
| 271 | + | ||
| 272 | + | require.NoError(t, err) | |
| 273 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |
| 274 | + | require.Equal(t, "https://example.com/link", clipboard.text) | |
| 275 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 276 | + | require.Zero(t, resolver.resolveCalls) | |
| 277 | + | require.Zero(t, saver.saveCalls) | |
| 278 | + | require.Zero(t, delegator.delegateCalls) | |
| 279 | + | } | |
M internal/application/usecase/transform_legacy_config.go +1 -0
| @@ -15,4 +15,5 @@ func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *Transf | |||
| 15 | 15 | // Execute transforms legacy config format in place. | |
| 16 | 16 | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | |
| 17 | 17 | uc.transformer.TransformLegacyActions(rawConfig) | |
| 18 | + | uc.transformer.TransformLegacyEngineConfig(rawConfig) | |
| 18 | 19 | } | |
M internal/application/usecase/transform_legacy_config_test.go +2 -1
| @@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |||
| 13 | 13 | rawConfig := map[string]any{"test": "data"} | |
| 14 | 14 | ||
| 15 | 15 | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | |
| 16 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return() | |
| 16 | 17 | ||
| 17 | 18 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 18 | 19 | uc.Execute(rawConfig) | |
| @@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |||
| 33 | 34 | }, | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | - | // Verify the transformer receives the exact rawConfig | |
| 37 | 37 | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | |
| 38 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return() | |
| 38 | 39 | ||
| 39 | 40 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 40 | 41 | uc.Execute(rawConfig) | |
M internal/bootstrap/engine.go +50 -25
| @@ -9,9 +9,11 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 12 | + | clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 15 | 17 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 16 | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 17 | 19 | "github.com/bnema/dumber/internal/ui/theme" | |
| @@ -20,13 +22,12 @@ import ( | |||
| 20 | 22 | ||
| 21 | 23 | // EngineInput holds the input for BuildEngine. | |
| 22 | 24 | type EngineInput struct { | |
| 23 | - | Ctx context.Context | |
| 24 | - | Config *config.Config | |
| 25 | - | DataDir string | |
| 26 | - | CacheDir string | |
| 27 | - | ThemeManager *theme.Manager | |
| 28 | - | ColorResolver port.ColorSchemeResolver | |
| 29 | - | Logger zerolog.Logger | |
| 25 | + | Ctx context.Context | |
| 26 | + | Config *config.Config | |
| 27 | + | RuntimeProfile runtimeprofile.Profile | |
| 28 | + | ThemeManager *theme.Manager | |
| 29 | + | ColorResolver port.ColorSchemeResolver | |
| 30 | + | Logger zerolog.Logger | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | |
| @@ -45,35 +46,42 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 45 | 46 | } | |
| 46 | 47 | currentConfigPayload := buildConfigPayload(systemviewUC.Current) | |
| 47 | 48 | defaultConfigPayload := buildConfigPayload(systemviewUC.Default) | |
| 49 | + | contextMenuBuilder := usecase.NewBuildContextMenuUseCase() | |
| 50 | + | contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{} | |
| 48 | 51 | engineType := cfg.Engine.ResolveEngineType() | |
| 49 | 52 | switch engineType { | |
| 50 | 53 | case config.EngineTypeWebKit: | |
| 54 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 51 | 58 | opts := port.EngineOptions{ | |
| 52 | - | DataDir: input.DataDir, | |
| 53 | - | CacheDir: input.CacheDir, | |
| 54 | 59 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 55 | 60 | } | |
| 56 | 61 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | |
| 57 | 62 | ||
| 58 | 63 | return webkit.NewEngine( | |
| 59 | - | input.Ctx, cfg, opts, wkCfg, | |
| 64 | + | input.Ctx, cfg, opts, profile, wkCfg, | |
| 60 | 65 | currentConfigPayload, defaultConfigPayload, | |
| 61 | - | input.ThemeManager, input.ColorResolver, input.Logger, | |
| 66 | + | input.ThemeManager, input.ColorResolver, | |
| 67 | + | contextMenuBuilder, contextMenuExecutorFactory, | |
| 68 | + | input.Logger, | |
| 62 | 69 | ) | |
| 63 | 70 | case config.EngineTypeCEF: | |
| 71 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 72 | + | if err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 64 | 75 | opts := port.EngineOptions{ | |
| 65 | - | DataDir: input.DataDir, | |
| 66 | - | CacheDir: input.CacheDir, | |
| 67 | 76 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 68 | 77 | } | |
| 69 | 78 | cefCfg := cef.RuntimeConfig{ | |
| 70 | - | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 71 | - | LogFile: cfg.Engine.CEF.LogFile, | |
| 72 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 73 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 74 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 75 | - | EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler, | |
| 76 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 79 | + | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 80 | + | LogFile: cfg.Engine.CEF.LogFile, | |
| 81 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 82 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 83 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 84 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 77 | 85 | } | |
| 78 | 86 | transcodingCfg := cef.TranscodingRuntimeConfig{ | |
| 79 | 87 | Enabled: cfg.Transcoding.Enabled, | |
| @@ -82,10 +90,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 82 | 90 | Quality: cfg.Transcoding.Quality, | |
| 83 | 91 | } | |
| 84 | 92 | deps := cef.EngineDependencies{ | |
| 85 | - | RegisterHandlers: handlers.RegisterAll, | |
| 86 | - | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | |
| 87 | - | CurrentConfigPayload: currentConfigPayload, | |
| 88 | - | DefaultConfigPayload: defaultConfigPayload, | |
| 93 | + | RegisterHandlers: handlers.RegisterAll, | |
| 94 | + | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | |
| 95 | + | CurrentConfigPayload: currentConfigPayload, | |
| 96 | + | DefaultConfigPayload: defaultConfigPayload, | |
| 97 | + | ContextMenuBuilder: contextMenuBuilder, | |
| 98 | + | ContextMenuExecutorFactory: contextMenuExecutorFactory, | |
| 99 | + | Clipboard: clipboardinfra.New(), | |
| 100 | + | ImageDataResolver: webkit.NewContextMenuResolver(), | |
| 89 | 101 | MediaClassifier: cef.MediaClassifier{ | |
| 90 | 102 | IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME, | |
| 91 | 103 | IsOpenVideoMIME: transcoder.IsOpenVideoMIME, | |
| @@ -96,8 +108,21 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 96 | 108 | }, | |
| 97 | 109 | } | |
| 98 | 110 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 99 | - | return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps) | |
| 111 | + | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | |
| 112 | + | StateRoot: profile.CEFUserDataDir(), | |
| 113 | + | LogFile: profile.CEFLogFile(), | |
| 114 | + | }, cefCfg, transcodingCfg, audioFactory, deps) | |
| 100 | 115 | default: | |
| 101 | 116 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 102 | 117 | } | |
| 103 | 118 | } | |
| 119 | + | ||
| 120 | + | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | |
| 121 | + | if profile.Engine == "" { | |
| 122 | + | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | |
| 123 | + | } | |
| 124 | + | if profile.Engine != engineType { | |
| 125 | + | return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType) | |
| 126 | + | } | |
| 127 | + | return profile, nil | |
| 128 | + | } | |
M internal/bootstrap/engine_test.go +33 -3
| @@ -2,10 +2,12 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) { | |||
| 21 | 23 | cfg := config.DefaultConfig() | |
| 22 | 24 | cfg.Engine.Type = "cef" | |
| 23 | 25 | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 24 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 26 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 25 | 27 | require.Error(t, err) | |
| 26 | 28 | require.Contains(t, err.Error(), "cef.InitWithApp") | |
| 27 | 29 | } | |
| @@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) { | |||
| 30 | 32 | cfg := config.DefaultConfig() | |
| 31 | 33 | cfg.Engine.Type = "cef" | |
| 32 | 34 | cfg.Engine.CookiePolicy = config.CookiePolicyNever | |
| 33 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 35 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 34 | 36 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | |
| 35 | 37 | } | |
| 36 | 38 | ||
| @@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |||
| 39 | 41 | cfg.Engine.Type = "cef" | |
| 40 | 42 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | |
| 41 | 43 | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 42 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | |
| 44 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 43 | 45 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | |
| 44 | 46 | } | |
| 47 | + | ||
| 48 | + | func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) { | |
| 49 | + | cfg := config.DefaultConfig() | |
| 50 | + | cfg.Engine.Type = config.EngineTypeCEF | |
| 51 | + | cfg.Engine.CEF.CEFDir = t.TempDir() | |
| 52 | + | ||
| 53 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |
| 54 | + | require.Error(t, err) | |
| 55 | + | require.NotContains(t, err.Error(), "missing runtime profile") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func testRuntimeProfile(t *testing.T) runtimeprofile.Profile { | |
| 59 | + | t.Helper() | |
| 60 | + | root := t.TempDir() | |
| 61 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 62 | + | Env: func(string) string { return "" }, | |
| 63 | + | Engine: config.EngineTypeCEF, | |
| 64 | + | CWD: func() (string, error) { return root, nil }, | |
| 65 | + | Base: runtimeprofile.BasePaths{ | |
| 66 | + | ConfigHome: filepath.Join(root, "config"), | |
| 67 | + | DataHome: filepath.Join(root, "data"), | |
| 68 | + | StateHome: filepath.Join(root, "state"), | |
| 69 | + | CacheHome: filepath.Join(root, "cache"), | |
| 70 | + | }, | |
| 71 | + | }) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | return profile | |
| 74 | + | } | |
M internal/bootstrap/gui.go +31 -35
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "database/sql" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "time" | |
| 12 | 11 | ||
| @@ -18,6 +17,7 @@ import ( | |||
| 18 | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 19 | 18 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 20 | 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 21 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 22 | "github.com/bnema/dumber/internal/ui/theme" | |
| 23 | 23 | ) | |
| @@ -30,8 +30,7 @@ type DatabaseResult struct { | |||
| 30 | 30 | ||
| 31 | 31 | // ParallelInitResult holds the results of parallel initialization phase. | |
| 32 | 32 | type ParallelInitResult struct { | |
| 33 | - | DataDir string | |
| 34 | - | CacheDir string | |
| 33 | + | RuntimeProfile runtimeprofile.Profile | |
| 35 | 34 | ThemeManager *theme.Manager | |
| 36 | 35 | ColorResolver port.ColorSchemeResolver | |
| 37 | 36 | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| @@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||
| 91 | 90 | // Returns the first fatal error encountered, or nil with the results. | |
| 92 | 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 93 | 92 | var ( | |
| 94 | - | dataDir, cacheDir string | |
| 95 | - | dirsErr error | |
| 96 | - | wg sync.WaitGroup | |
| 93 | + | dirsErr error | |
| 94 | + | wg sync.WaitGroup | |
| 97 | 95 | ) | |
| 98 | 96 | ||
| 97 | + | profile, err := ResolveRuntimeProfile(input.Config) | |
| 98 | + | if err != nil { | |
| 99 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 99 | 102 | start := time.Now() | |
| 100 | 103 | ||
| 101 | 104 | // Create color scheme resolver with initial detectors. | |
| @@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 116 | 119 | // Resolve directories | |
| 117 | 120 | go func() { | |
| 118 | 121 | defer wg.Done() | |
| 119 | - | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | |
| 122 | + | dirsErr = resolveWebKitDirs(profile) | |
| 120 | 123 | }() | |
| 121 | 124 | ||
| 122 | 125 | // Theme manager (CPU-bound, no I/O) | |
| @@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | return &ParallelInitResult{ | |
| 141 | - | DataDir: dataDir, | |
| 142 | - | CacheDir: cacheDir, | |
| 144 | + | RuntimeProfile: profile, | |
| 143 | 145 | ThemeManager: themeManager, | |
| 144 | 146 | ColorResolver: resolver, | |
| 145 | 147 | AdwaitaDetector: adwaitaDetector, | |
| @@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |||
| 218 | 220 | } | |
| 219 | 221 | ||
| 220 | 222 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | |
| 221 | - | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | |
| 222 | - | const cacheDirPerm = 0o755 | |
| 223 | - | dataDir, err = config.GetDataDir() | |
| 224 | - | if err != nil { | |
| 225 | - | return "", "", fmt.Errorf("resolve data directory: %w", err) | |
| 223 | + | func resolveWebKitDirs(profile runtimeprofile.Profile) error { | |
| 224 | + | const dirPerm = 0o755 | |
| 225 | + | dataDir := profile.WebKitDataDir() | |
| 226 | + | cacheDir := profile.WebKitCacheDir() | |
| 227 | + | if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil { | |
| 228 | + | return fmt.Errorf("create data directory %s: %w", dataDir, mkErr) | |
| 226 | 229 | } | |
| 227 | - | stateDir, err := config.GetStateDir() | |
| 228 | - | if err != nil { | |
| 229 | - | return "", "", fmt.Errorf("resolve state directory: %w", err) | |
| 230 | - | } | |
| 231 | - | cacheDir = filepath.Join(stateDir, "webkit-cache") | |
| 232 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |
| 233 | - | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 230 | + | if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil { | |
| 231 | + | return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 234 | 232 | } | |
| 235 | - | return dataDir, cacheDir, nil | |
| 233 | + | return nil | |
| 236 | 234 | } | |
| 237 | 235 | ||
| 238 | 236 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | |
| @@ -277,12 +275,11 @@ type ParallelDBEngineResult struct { | |||
| 277 | 275 | ||
| 278 | 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | |
| 279 | 277 | type ParallelDBEngineInput struct { | |
| 280 | - | Ctx context.Context | |
| 281 | - | Config *config.Config | |
| 282 | - | DataDir string // For engine context | |
| 283 | - | CacheDir string // For engine cache | |
| 284 | - | ThemeManager *theme.Manager | |
| 285 | - | ColorResolver port.ColorSchemeResolver | |
| 278 | + | Ctx context.Context | |
| 279 | + | Config *config.Config | |
| 280 | + | RuntimeProfile runtimeprofile.Profile | |
| 281 | + | ThemeManager *theme.Manager | |
| 282 | + | ColorResolver port.ColorSchemeResolver | |
| 286 | 283 | } | |
| 287 | 284 | ||
| 288 | 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | |
| @@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||
| 317 | 314 | ||
| 318 | 315 | // Engine on main thread (GTK requirement) | |
| 319 | 316 | engine, err := BuildEngine(EngineInput{ | |
| 320 | - | Ctx: input.Ctx, | |
| 321 | - | Config: input.Config, | |
| 322 | - | DataDir: input.DataDir, | |
| 323 | - | CacheDir: input.CacheDir, | |
| 324 | - | ThemeManager: input.ThemeManager, | |
| 325 | - | ColorResolver: input.ColorResolver, | |
| 326 | - | Logger: *log, | |
| 317 | + | Ctx: input.Ctx, | |
| 318 | + | Config: input.Config, | |
| 319 | + | RuntimeProfile: input.RuntimeProfile, | |
| 320 | + | ThemeManager: input.ThemeManager, | |
| 321 | + | ColorResolver: input.ColorResolver, | |
| 322 | + | Logger: *log, | |
| 327 | 323 | }) | |
| 328 | 324 | if err != nil { | |
| 329 | 325 | dbRes := <-dbCh | |
A internal/bootstrap/runtime_profile.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap. | |
| 12 | + | func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |
| 13 | + | engine := config.EngineTypeWebKit | |
| 14 | + | if cfg != nil { | |
| 15 | + | engine = cfg.Engine.ResolveEngineType() | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | dirs, err := config.GetXDGDirs() | |
| 19 | + | if err != nil { | |
| 20 | + | return runtimeprofile.Profile{}, err | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 24 | + | Env: os.Getenv, | |
| 25 | + | CWD: os.Getwd, | |
| 26 | + | Engine: engine, | |
| 27 | + | Base: runtimeprofile.BasePaths{ | |
| 28 | + | ConfigHome: dirs.ConfigHome, | |
| 29 | + | DataHome: dirs.DataHome, | |
| 30 | + | StateHome: dirs.StateHome, | |
| 31 | + | CacheHome: dirs.CacheHome, | |
| 32 | + | }, | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ResolveXDGRuntimeDir returns the shared XDG runtime dir for the current mode. | |
| 37 | + | func ResolveXDGRuntimeDir(profile runtimeprofile.Profile) string { | |
| 38 | + | if profile.Mode == runtimeprofile.ModeDev { | |
| 39 | + | if profile.Shared.RootDir != "" { | |
| 40 | + | return filepath.Join(profile.Shared.RootDir, "runtime") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if profile.Shared.StateDir != "" { | |
| 44 | + | return filepath.Join(profile.Shared.StateDir, "runtime") | |
| 45 | + | } | |
| 46 | + | return "" | |
| 47 | + | } | |
A internal/bootstrap/runtime_profile_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) { | |
| 13 | + | cfg := config.DefaultConfig() | |
| 14 | + | cfg.Engine.Type = "webkit" | |
| 15 | + | t.Setenv("DUMBER_ENGINE", "cef") | |
| 16 | + | t.Setenv("ENV", "") | |
| 17 | + | home := t.TempDir() | |
| 18 | + | t.Setenv("HOME", home) | |
| 19 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 20 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 21 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 22 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 23 | + | ||
| 24 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.Equal(t, "cef", profile.Engine) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) { | |
| 30 | + | cfg := config.DefaultConfig() | |
| 31 | + | cfg.Engine.Type = config.EngineTypeWebKit | |
| 32 | + | t.Setenv("ENV", "") | |
| 33 | + | home := t.TempDir() | |
| 34 | + | t.Setenv("HOME", home) | |
| 35 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 36 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 37 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 38 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 39 | + | ||
| 40 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir) | |
| 43 | + | require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir) | |
| 44 | + | require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir) | |
| 45 | + | require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |
| 49 | + | cfg := config.DefaultConfig() | |
| 50 | + | cfg.Engine.Type = config.EngineTypeCEF | |
| 51 | + | ||
| 52 | + | wd := t.TempDir() | |
| 53 | + | t.Chdir(wd) | |
| 54 | + | ||
| 55 | + | t.Setenv("ENV", "dev") | |
| 56 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir) | |
| 59 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | |
| 60 | + | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) { | |
| 64 | + | profile := runtimeprofile.Profile{ | |
| 65 | + | Mode: runtimeprofile.ModeDev, | |
| 66 | + | Shared: runtimeprofile.SharedPaths{ | |
| 67 | + | RootDir: "/tmp/project/.dev/dumber", | |
| 68 | + | StateDir: "/tmp/project/.dev/dumber/state", | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | require.Equal(t, "/tmp/project/.dev/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestResolveXDGRuntimeDir_UsesStateRuntimeRootInProd(t *testing.T) { | |
| 76 | + | profile := runtimeprofile.Profile{ | |
| 77 | + | Mode: runtimeprofile.ModeProd, | |
| 78 | + | Shared: runtimeprofile.SharedPaths{ | |
| 79 | + | StateDir: "/tmp/dumber-state/dumber", | |
| 80 | + | }, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | require.Equal(t, "/tmp/dumber-state/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |
| 84 | + | } | |
A internal/bootstrap/session_spawner.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewSessionSpawner wires the session spawner with any engine-specific launch | |
| 14 | + | // environment required by the current browser backend. | |
| 15 | + | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { | |
| 16 | + | var spawnEnv port.SessionSpawnEnvironment | |
| 17 | + | if profile.Engine == config.EngineTypeCEF { | |
| 18 | + | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) | |
| 19 | + | } | |
| 20 | + | return desktop.NewSessionSpawner(ctx, spawnEnv) | |
| 21 | + | } | |
M internal/cli/app.go +7 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 13 | 14 | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/build" | |
| 15 | 16 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -37,6 +38,7 @@ type App struct { | |||
| 37 | 38 | ||
| 38 | 39 | // Services | |
| 39 | 40 | FaviconService *favicon.Service | |
| 41 | + | SessionSpawner port.SessionSpawner | |
| 40 | 42 | ||
| 41 | 43 | // Context with logger | |
| 42 | 44 | ctx context.Context | |
| @@ -49,6 +51,10 @@ func NewApp() (*App, error) { | |||
| 49 | 51 | var err error | |
| 50 | 52 | // Load config | |
| 51 | 53 | cfg := loadConfig() | |
| 54 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 57 | + | } | |
| 52 | 58 | ||
| 53 | 59 | // Create theme from config | |
| 54 | 60 | theme := styles.NewTheme(cfg) | |
| @@ -131,6 +137,7 @@ func NewApp() (*App, error) { | |||
| 131 | 137 | RestoreUC: restoreUC, | |
| 132 | 138 | DeleteSessionUC: deleteSessionUC, | |
| 133 | 139 | FaviconService: faviconService, | |
| 140 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile), | |
| 134 | 141 | ctx: ctx, | |
| 135 | 142 | logCleanup: logCleanup, | |
| 136 | 143 | }, nil | |
M internal/cli/cmd/gen_docs.go +1 -1
| @@ -57,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error { | |||
| 57 | 57 | if outputDir == "" { | |
| 58 | 58 | switch genDocsFormat { | |
| 59 | 59 | case "man": | |
| 60 | - | xdg := xdgadapter.New() | |
| 60 | + | xdg := xdgadapter.New(false, "") | |
| 61 | 61 | manDir, err := xdg.ManDir() | |
| 62 | 62 | if err != nil { | |
| 63 | 63 | return fmt.Errorf("resolve man directory: %w", err) | |
M internal/cli/cmd/purge.go +11 -2
| @@ -7,11 +7,13 @@ import ( | |||
| 7 | 7 | "github.com/spf13/cobra" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 10 | 11 | "github.com/bnema/dumber/internal/cli" | |
| 11 | 12 | "github.com/bnema/dumber/internal/cli/model" | |
| 12 | 13 | "github.com/bnema/dumber/internal/cli/styles" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 15 | 17 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| @@ -47,7 +49,14 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | fsAdapter := filesystem.New() | |
| 50 | - | xdgAdapter := xdgadapter.New() | |
| 52 | + | profile, err := bootstrap.ResolveRuntimeProfile(app.Config) | |
| 53 | + | if err != nil { | |
| 54 | + | return fmt.Errorf("resolve runtime profile: %w", err) | |
| 55 | + | } | |
| 56 | + | xdgAdapter := xdgadapter.New( | |
| 57 | + | profile.Mode == runtimeprofile.ModeDev, | |
| 58 | + | bootstrap.ResolveXDGRuntimeDir(profile), | |
| 59 | + | ) | |
| 51 | 60 | desktopAdapter := desktop.New() | |
| 52 | 61 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | |
| 53 | 62 | ||
| @@ -63,7 +72,7 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 63 | 72 | ||
| 64 | 73 | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | |
| 65 | 74 | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 66 | - | _, err := p.Run() | |
| 75 | + | _, err = p.Run() | |
| 67 | 76 | return err | |
| 68 | 77 | } | |
| 69 | 78 | ||
M internal/cli/cmd/sessions.go +7 -3
| @@ -13,7 +13,6 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/cli/model" | |
| 14 | 14 | "github.com/bnema/dumber/internal/cli/styles" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | 18 | const defaultSessionsLimit = 20 | |
| @@ -60,6 +59,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||
| 60 | 59 | ListSessionsUC: app.ListSessionsUC, | |
| 61 | 60 | RestoreUC: app.RestoreUC, | |
| 62 | 61 | DeleteSessionUC: app.DeleteSessionUC, | |
| 62 | + | SessionSpawner: app.SessionSpawner, | |
| 63 | 63 | CurrentSession: currentSessionID, | |
| 64 | 64 | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | |
| 65 | 65 | }) | |
| @@ -181,8 +181,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | 183 | // Spawn a new dumber instance with the session | |
| 184 | - | spawner := desktop.NewSessionSpawner(app.Ctx()) | |
| 185 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 184 | + | if app.SessionSpawner == nil { | |
| 185 | + | err := fmt.Errorf("session spawner not available") | |
| 186 | + | fmt.Fprintln(os.Stderr, renderer.RenderError(err)) | |
| 187 | + | return wrapPrintedError(err) | |
| 188 | + | } | |
| 189 | + | if err := app.SessionSpawner.SpawnWithSession(sessionID); err != nil { | |
| 186 | 190 | wrappedErr := fmt.Errorf("spawn browser: %w", err) | |
| 187 | 191 | fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) | |
| 188 | 192 | return wrapPrintedError(wrappedErr) | |
M internal/cli/model/sessions.go +8 -3
| @@ -11,10 +11,10 @@ import ( | |||
| 11 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 12 | "github.com/charmbracelet/lipgloss" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 15 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 16 | "github.com/bnema/dumber/internal/cli/styles" | |
| 16 | 17 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 18 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| @@ -43,6 +43,7 @@ type SessionsModel struct { | |||
| 43 | 43 | listSessionsUC *usecase.ListSessionsUseCase | |
| 44 | 44 | restoreUC *usecase.RestoreSessionUseCase | |
| 45 | 45 | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 46 | + | sessionSpawner port.SessionSpawner | |
| 46 | 47 | theme *styles.Theme | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -114,6 +115,7 @@ type SessionsModelConfig struct { | |||
| 114 | 115 | ListSessionsUC *usecase.ListSessionsUseCase | |
| 115 | 116 | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | 117 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 118 | + | SessionSpawner port.SessionSpawner | |
| 117 | 119 | CurrentSession entity.SessionID | |
| 118 | 120 | MaxExitedSessions int | |
| 119 | 121 | } | |
| @@ -137,6 +139,7 @@ func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsMode | |||
| 137 | 139 | listSessionsUC: cfg.ListSessionsUC, | |
| 138 | 140 | restoreUC: cfg.RestoreUC, | |
| 139 | 141 | deleteSessionUC: cfg.DeleteSessionUC, | |
| 142 | + | sessionSpawner: cfg.SessionSpawner, | |
| 140 | 143 | currentSession: cfg.CurrentSession, | |
| 141 | 144 | theme: theme, | |
| 142 | 145 | } | |
| @@ -336,8 +339,10 @@ func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd { | |||
| 336 | 339 | } | |
| 337 | 340 | ||
| 338 | 341 | // Spawn a new dumber instance | |
| 339 | - | spawner := desktop.NewSessionSpawner(m.ctx) | |
| 340 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 342 | + | if m.sessionSpawner == nil { | |
| 343 | + | return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("session spawner not available")} | |
| 344 | + | } | |
| 345 | + | if err := m.sessionSpawner.SpawnWithSession(sessionID); err != nil { | |
| 341 | 346 | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 342 | 347 | } | |
| 343 | 348 | ||
A internal/domain/download/policy.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "mime" | |
| 5 | + | "net/url" | |
| 6 | + | "path" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var forcedDownloadExtensions = map[string]struct{}{ | |
| 11 | + | ".7z": {}, | |
| 12 | + | ".apk": {}, | |
| 13 | + | ".appimage": {}, | |
| 14 | + | ".bz2": {}, | |
| 15 | + | ".deb": {}, | |
| 16 | + | ".dmg": {}, | |
| 17 | + | ".exe": {}, | |
| 18 | + | ".gz": {}, | |
| 19 | + | ".img": {}, | |
| 20 | + | ".iso": {}, | |
| 21 | + | ".msi": {}, | |
| 22 | + | ".pdf": {}, | |
| 23 | + | ".pkg": {}, | |
| 24 | + | ".rar": {}, | |
| 25 | + | ".rpm": {}, | |
| 26 | + | ".tar": {}, | |
| 27 | + | ".tgz": {}, | |
| 28 | + | ".xz": {}, | |
| 29 | + | ".zip": {}, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | var forcedDownloadMIMETypes = map[string]struct{}{ | |
| 33 | + | "application/pdf": {}, | |
| 34 | + | "application/x-apple-diskimage": {}, | |
| 35 | + | "application/x-cd-image": {}, | |
| 36 | + | "application/x-iso9660-image": {}, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ShouldForceDownload returns true when the resource should be downloaded | |
| 40 | + | // instead of navigated/rendered inline. | |
| 41 | + | func ShouldForceDownload(uri, mimeType string) bool { | |
| 42 | + | return ShouldForceDownloadForMIMEType(mimeType) || ShouldForceDownloadForURI(uri) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ShouldForceDownloadForURI returns true when the URI path has a known | |
| 46 | + | // download-only extension. | |
| 47 | + | func ShouldForceDownloadForURI(uri string) bool { | |
| 48 | + | ext := strings.ToLower(path.Ext(uriPath(uri))) | |
| 49 | + | if ext == "" { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | _, ok := forcedDownloadExtensions[ext] | |
| 53 | + | return ok | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ShouldForceDownloadForMIMEType returns true when the MIME type maps to a | |
| 57 | + | // known download-only artifact. | |
| 58 | + | func ShouldForceDownloadForMIMEType(mimeType string) bool { | |
| 59 | + | if mimeType == "" { | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | mediaType := strings.ToLower(strings.TrimSpace(mimeType)) | |
| 64 | + | if parsed, _, err := mime.ParseMediaType(mediaType); err == nil { | |
| 65 | + | mediaType = parsed | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | _, ok := forcedDownloadMIMETypes[mediaType] | |
| 69 | + | return ok | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func uriPath(raw string) string { | |
| 73 | + | if raw == "" { | |
| 74 | + | return "" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | parsed, err := url.Parse(raw) | |
| 78 | + | if err == nil && parsed.Path != "" { | |
| 79 | + | return parsed.Path | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if cut := strings.IndexAny(raw, "?#"); cut >= 0 { | |
| 83 | + | return raw[:cut] | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return raw | |
| 87 | + | } | |
A internal/domain/download/policy_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestShouldForceDownloadForURI(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | uri string | |
| 9 | + | expected bool | |
| 10 | + | }{ | |
| 11 | + | {name: "pdf path", uri: "https://example.com/file.pdf", expected: true}, | |
| 12 | + | {name: "iso path", uri: "https://example.com/linux.iso?mirror=1", expected: true}, | |
| 13 | + | {name: "archive path", uri: "https://example.com/release.tar.gz", expected: true}, | |
| 14 | + | {name: "plain html", uri: "https://example.com/index.html", expected: false}, | |
| 15 | + | {name: "empty", uri: "", expected: false}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | for _, tt := range tests { | |
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |
| 20 | + | if got := ShouldForceDownloadForURI(tt.uri); got != tt.expected { | |
| 21 | + | t.Fatalf("ShouldForceDownloadForURI(%q) = %v, want %v", tt.uri, got, tt.expected) | |
| 22 | + | } | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestShouldForceDownloadForMIMEType(t *testing.T) { | |
| 28 | + | tests := []struct { | |
| 29 | + | name string | |
| 30 | + | mimeType string | |
| 31 | + | expected bool | |
| 32 | + | }{ | |
| 33 | + | {name: "pdf", mimeType: "application/pdf", expected: true}, | |
| 34 | + | {name: "pdf with params", mimeType: "application/pdf; charset=binary", expected: true}, | |
| 35 | + | {name: "iso image", mimeType: "application/x-iso9660-image", expected: true}, | |
| 36 | + | {name: "html", mimeType: "text/html", expected: false}, | |
| 37 | + | {name: "empty", mimeType: "", expected: false}, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | if got := ShouldForceDownloadForMIMEType(tt.mimeType); got != tt.expected { | |
| 43 | + | t.Fatalf("ShouldForceDownloadForMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.expected) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestShouldForceDownload(t *testing.T) { | |
| 50 | + | if !ShouldForceDownload("https://example.com/file.iso", "") { | |
| 51 | + | t.Fatal("expected URI extension to trigger forced download") | |
| 52 | + | } | |
| 53 | + | if !ShouldForceDownload("", "application/pdf") { | |
| 54 | + | t.Fatal("expected MIME type to trigger forced download") | |
| 55 | + | } | |
| 56 | + | if ShouldForceDownload("https://example.com/", "text/html") { | |
| 57 | + | t.Fatal("did not expect regular HTML to trigger forced download") | |
| 58 | + | } | |
| 59 | + | } | |
A internal/domain/entity/image_data.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | // ImageData contains resolved image bytes and MIME type metadata. | |
| 4 | + | type ImageData struct { | |
| 5 | + | Bytes []byte | |
| 6 | + | MimeType string | |
| 7 | + | } | |
A internal/domain/errors/errors.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package domainerrors | |
| 2 | + | ||
| 3 | + | type domainError string | |
| 4 | + | ||
| 5 | + | func (e domainError) Error() string { | |
| 6 | + | return string(e) | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | // ErrRelatedWebViewUnsupported reports that the active engine cannot create | |
| 10 | + | // a popup WebView related to an existing parent WebView. | |
| 11 | + | var ErrRelatedWebViewUnsupported error = domainError("related popup webviews are not supported") | |
M internal/infrastructure/cef/app.go +107 -9
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/logging" | |
| @@ -43,6 +45,7 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||
| 43 | 45 | type dumberApp struct { | |
| 44 | 46 | engine *Engine | |
| 45 | 47 | bph purecef.BrowserProcessHandler | |
| 48 | + | rph purecef.RenderProcessHandler | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 48 | 51 | // newDumberApp creates an App whose BrowserProcessHandler handles context | |
| @@ -51,13 +54,16 @@ type dumberApp struct { | |||
| 51 | 54 | func newDumberApp(engine *Engine) purecef.App { | |
| 52 | 55 | app := &dumberApp{engine: engine} | |
| 53 | 56 | app.bph = &dumberBPH{engine: engine} | |
| 57 | + | app.rph = newPopupOpenerRenderProcessHandler() | |
| 54 | 58 | return app | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 57 | - | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | |
| 58 | - | // the same custom scheme registration in renderer/GPU/utility processes. | |
| 61 | + | // NewSubprocessApp returns a lightweight raw App implementation for CEF | |
| 62 | + | // subprocesses re-executed from the main dumber binary. Keep the legacy | |
| 63 | + | // renderer bridge disabled, but allow the minimal popup-opener render handler | |
| 64 | + | // needed to install synthetic opener state before page scripts run. | |
| 59 | 65 | func NewSubprocessApp() purecef.App { | |
| 60 | - | return purecef.NewApp(&subprocessApp{}) | |
| 66 | + | return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()} | |
| 61 | 67 | } | |
| 62 | 68 | ||
| 63 | 69 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | |
| @@ -87,9 +93,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | |||
| 87 | 93 | } | |
| 88 | 94 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 89 | 95 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | |
| 90 | - | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | |
| 96 | + | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } | |
| 91 | 97 | ||
| 92 | - | type subprocessApp struct{} | |
| 98 | + | type subprocessApp struct{ rph purecef.RenderProcessHandler } | |
| 93 | 99 | ||
| 94 | 100 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | |
| 95 | 101 | configureCommandLine(commandLine) | |
| @@ -99,7 +105,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra | |||
| 99 | 105 | } | |
| 100 | 106 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 101 | 107 | func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil } | |
| 102 | - | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | |
| 108 | + | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } | |
| 103 | 109 | ||
| 104 | 110 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | |
| 105 | 111 | // child process launch tracking, and diagnostic logging. | |
| @@ -118,6 +124,7 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 118 | 124 | useAngle := "" | |
| 119 | 125 | ozonePlatform := "" | |
| 120 | 126 | if commandLine != nil { | |
| 127 | + | appendSwitchIfMissing(commandLine, "no-zygote") | |
| 121 | 128 | processType = commandLine.GetSwitchValue("type") | |
| 122 | 129 | commandLineString = commandLine.GetCommandLineString() | |
| 123 | 130 | useAngle = commandLine.GetSwitchValue("use-angle") | |
| @@ -125,9 +132,100 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 125 | 132 | } | |
| 126 | 133 | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString) | |
| 127 | 134 | } | |
| 128 | - | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 } | |
| 129 | - | func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil } | |
| 130 | - | func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil } | |
| 135 | + | ||
| 136 | + | func appendSwitchIfMissing(commandLine purecef.CommandLine, name string) { | |
| 137 | + | if commandLine == nil || commandLine.HasSwitch(name) { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | commandLine.AppendSwitch(name) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func parseRelaunchCommandLineArgs(commandLine string) []string { | |
| 144 | + | args := make([]string, 0, 4) | |
| 145 | + | var current strings.Builder | |
| 146 | + | var quote rune | |
| 147 | + | escaped := false | |
| 148 | + | flush := func() { | |
| 149 | + | if current.Len() == 0 { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | args = append(args, current.String()) | |
| 153 | + | current.Reset() | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | for _, r := range commandLine { | |
| 157 | + | switch { | |
| 158 | + | case escaped: | |
| 159 | + | current.WriteRune(r) | |
| 160 | + | escaped = false | |
| 161 | + | case r == '\\': | |
| 162 | + | escaped = true | |
| 163 | + | case quote != 0: | |
| 164 | + | if r == quote { | |
| 165 | + | quote = 0 | |
| 166 | + | continue | |
| 167 | + | } | |
| 168 | + | current.WriteRune(r) | |
| 169 | + | case r == '"' || r == '\'': | |
| 170 | + | quote = r | |
| 171 | + | case r == ' ' || r == '\t' || r == '\n' || r == '\r': | |
| 172 | + | flush() | |
| 173 | + | default: | |
| 174 | + | current.WriteRune(r) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | if escaped { | |
| 179 | + | current.WriteRune('\\') | |
| 180 | + | } | |
| 181 | + | flush() | |
| 182 | + | ||
| 183 | + | return args | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bool) { | |
| 187 | + | if commandLine == nil { | |
| 188 | + | return "", false | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) | |
| 192 | + | if len(args) < 3 || args[1] != "browse" || args[2] == "" { | |
| 193 | + | return "", false | |
| 194 | + | } | |
| 195 | + | return args[2], true | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |
| 199 | + | if commandLine == nil { | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) | |
| 204 | + | return len(args) >= 2 && args[1] == "browse" | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string { | |
| 208 | + | browseURL, _ := parseBrowseRelaunchCommandLine(commandLine) | |
| 209 | + | return browseURL | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 { | |
| 213 | + | if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok { | |
| 214 | + | if h != nil && h.engine != nil { | |
| 215 | + | if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil { | |
| 216 | + | handler(browseURL) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | return 1 | |
| 220 | + | } | |
| 221 | + | if isBrowseRelaunchCommandLine(commandLine) { | |
| 222 | + | return 1 | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return 0 | |
| 226 | + | } | |
| 227 | + | func (h *dumberBPH) GetDefaultClient() purecef.RawClient { return nil } | |
| 228 | + | func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil } | |
| 131 | 229 | ||
| 132 | 230 | // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives | |
| 133 | 231 | // its own pump. Required by the BrowserProcessHandler interface. | |
M internal/infrastructure/cef/app_test.go +159 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 6 | + | "unsafe" | |
| 5 | 7 | ||
| 6 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 9 | ) | |
| @@ -19,6 +21,61 @@ func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) | |||
| 19 | 21 | return 1 | |
| 20 | 22 | } | |
| 21 | 23 | ||
| 24 | + | type relaunchCommandLineStub struct { | |
| 25 | + | commandLineString string | |
| 26 | + | } | |
| 27 | + | ||
| 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(purecef.StringList) {} | |
| 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(purecef.StringMap) {} | |
| 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(purecef.StringList) {} | |
| 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 | + | } | |
| 78 | + | ||
| 22 | 79 | func TestRegisterDumbScheme(t *testing.T) { | |
| 23 | 80 | t.Parallel() | |
| 24 | 81 | ||
| @@ -41,3 +98,105 @@ func TestRegisterDumbScheme(t *testing.T) { | |||
| 41 | 98 | t.Fatalf("options = %d, want %d", stub.options, wantOptions) | |
| 42 | 99 | } | |
| 43 | 100 | } | |
| 101 | + | ||
| 102 | + | func TestParseBrowseURLFromRelaunchCommandLine(t *testing.T) { | |
| 103 | + | t.Parallel() | |
| 104 | + | ||
| 105 | + | got := parseBrowseURLFromRelaunchCommandLine(relaunchCommandLineStub{ | |
| 106 | + | commandLineString: "dumber browse https://example.com", | |
| 107 | + | }) | |
| 108 | + | ||
| 109 | + | if got != "https://example.com" { | |
| 110 | + | t.Fatalf("parsed browse url = %q, want %q", got, "https://example.com") | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | ||
| 117 | + | gotURL := "" | |
| 118 | + | eng := &Engine{} | |
| 119 | + | eng.SetAlreadyRunningAppRelaunchHandler(func(url string) { | |
| 120 | + | gotURL = url | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{ | |
| 124 | + | commandLineString: "dumber browse https://example.com", | |
| 125 | + | }, "") | |
| 126 | + | ||
| 127 | + | if ret != 1 { | |
| 128 | + | t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret) | |
| 129 | + | } | |
| 130 | + | if gotURL != "https://example.com" { | |
| 131 | + | t.Fatalf("forwarded browse url = %q, want %q", gotURL, "https://example.com") | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns1(t *testing.T) { | |
| 136 | + | t.Parallel() | |
| 137 | + | ||
| 138 | + | gotURL := "sentinel" | |
| 139 | + | eng := &Engine{} | |
| 140 | + | eng.SetAlreadyRunningAppRelaunchHandler(func(url string) { | |
| 141 | + | gotURL = url | |
| 142 | + | }) | |
| 143 | + | ||
| 144 | + | ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{ | |
| 145 | + | commandLineString: "dumber browse", | |
| 146 | + | }, "") | |
| 147 | + | ||
| 148 | + | if ret != 1 { | |
| 149 | + | t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret) | |
| 150 | + | } | |
| 151 | + | if gotURL != "sentinel" { | |
| 152 | + | t.Fatalf("forwarded browse url = %q, want %q", gotURL, "sentinel") | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardNonBrowse(t *testing.T) { | |
| 157 | + | t.Parallel() | |
| 158 | + | ||
| 159 | + | called := false | |
| 160 | + | eng := &Engine{} | |
| 161 | + | eng.SetAlreadyRunningAppRelaunchHandler(func(string) { | |
| 162 | + | called = true | |
| 163 | + | }) | |
| 164 | + | ||
| 165 | + | ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{ | |
| 166 | + | commandLineString: "dumber", | |
| 167 | + | }, "") | |
| 168 | + | ||
| 169 | + | if ret != 0 { | |
| 170 | + | t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 0", ret) | |
| 171 | + | } | |
| 172 | + | if called { | |
| 173 | + | t.Fatal("handler should not be called for non-browse relaunch") | |
| 174 | + | } | |
| 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 | + | } | |
A internal/infrastructure/cef/bridge_script_templates.go +808 -0
| @@ -0,0 +1,808 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // trustedPageFetchBridgeBaseJS is the invariant fetch-bridge bundle injected on | |
| 11 | + | // external pages. It is assembled once from shared templates and then only the | |
| 12 | + | // bridge nonce placeholder is replaced per navigation. | |
| 13 | + | var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS() | |
| 14 | + | ||
| 15 | + | // rendererBridgeExtensionJS is assembled once from the shared popup proxy | |
| 16 | + | // builder so the renderer-extension and fetch-based popup bridges stay aligned. | |
| 17 | + | var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS() | |
| 18 | + | ||
| 19 | + | type popupProxyBridgeDispatch struct { | |
| 20 | + | patchFlag string | |
| 21 | + | helpers string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | const popupProxyBridgeBodyTemplateJS = ` | |
| 25 | + | if (typeof window === 'undefined') return; | |
| 26 | + | if (window.__DUMBER_POPUP_PATCH_FLAG__) return; | |
| 27 | + | ||
| 28 | + | __DUMBER_POPUP_DISPATCH_HELPERS__ | |
| 29 | + | ||
| 30 | + | function resolvePopupURL(rawURL) { | |
| 31 | + | if (rawURL == null || rawURL === '') return ''; | |
| 32 | + | try { | |
| 33 | + | return new URL(String(rawURL), (document && document.baseURI) || location.href).href; | |
| 34 | + | } catch (_) { | |
| 35 | + | return String(rawURL); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | function hasUserGesture() { | |
| 40 | + | try { | |
| 41 | + | return !!(navigator && navigator.userActivation && navigator.userActivation.isActive); | |
| 42 | + | } catch (_) { | |
| 43 | + | return false; | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | function popupHasNoOpener(features) { | |
| 48 | + | if (features == null || features === '') return false; | |
| 49 | + | var normalized = String(features).toLowerCase(); | |
| 50 | + | return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function shouldDelegateWindowOpen(target) { | |
| 54 | + | return target === '_self' || target === '_top' || target === '_parent'; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | function createPopupProxyID() { | |
| 58 | + | var timestamp = Date.now(); | |
| 59 | + | try { | |
| 60 | + | if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') { | |
| 61 | + | var bytes = new Uint8Array(4); | |
| 62 | + | crypto.getRandomValues(bytes); | |
| 63 | + | var nonce = ''; | |
| 64 | + | for (var i = 0; i < bytes.length; i++) { | |
| 65 | + | nonce += ('0' + bytes[i].toString(16)).slice(-2); | |
| 66 | + | } | |
| 67 | + | return 'popup-' + timestamp + '-' + nonce; | |
| 68 | + | } | |
| 69 | + | } catch (_) {} | |
| 70 | + | return 'popup-' + timestamp + '-' + Math.random().toString(36).slice(2); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | function createSyntheticPopupProxy(proxyID, initialURL, features) { | |
| 74 | + | var href = initialURL || 'about:blank'; | |
| 75 | + | var closed = false; | |
| 76 | + | var noJavaScriptAccess = popupHasNoOpener(features); | |
| 77 | + | ||
| 78 | + | function navigate(nextURL) { | |
| 79 | + | href = resolvePopupURL(nextURL); | |
| 80 | + | dispatchPopupNavigate(proxyID, href); | |
| 81 | + | return href; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | var locationProxy = { | |
| 85 | + | assign: function(nextURL) { return navigate(nextURL); }, | |
| 86 | + | replace: function(nextURL) { return navigate(nextURL); }, | |
| 87 | + | toString: function() { return href; } | |
| 88 | + | }; | |
| 89 | + | try { | |
| 90 | + | Object.defineProperty(locationProxy, 'href', { | |
| 91 | + | configurable: true, | |
| 92 | + | enumerable: true, | |
| 93 | + | get: function() { return href; }, | |
| 94 | + | set: function(nextURL) { navigate(nextURL); } | |
| 95 | + | }); | |
| 96 | + | } catch (_) { | |
| 97 | + | locationProxy.href = href; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | var proxy = { | |
| 101 | + | blur: function() { return undefined; }, | |
| 102 | + | close: function() { | |
| 103 | + | if (closed) return; | |
| 104 | + | closed = true; | |
| 105 | + | dispatchPopupClose(proxyID); | |
| 106 | + | }, | |
| 107 | + | focus: function() { return undefined; }, | |
| 108 | + | postMessage: function() { return undefined; } | |
| 109 | + | }; | |
| 110 | + | proxy.opener = noJavaScriptAccess ? null : window; | |
| 111 | + | proxy.self = proxy; | |
| 112 | + | proxy.window = proxy; | |
| 113 | + | ||
| 114 | + | try { | |
| 115 | + | Object.defineProperty(proxy, 'closed', { | |
| 116 | + | configurable: true, | |
| 117 | + | enumerable: true, | |
| 118 | + | get: function() { return closed; }, | |
| 119 | + | set: function(next) { closed = !!next; } | |
| 120 | + | }); | |
| 121 | + | } catch (_) { | |
| 122 | + | proxy.closed = false; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | try { | |
| 126 | + | Object.defineProperty(proxy, 'location', { | |
| 127 | + | configurable: true, | |
| 128 | + | enumerable: true, | |
| 129 | + | get: function() { return locationProxy; }, | |
| 130 | + | set: function(nextURL) { navigate(nextURL); } | |
| 131 | + | }); | |
| 132 | + | } catch (_) { | |
| 133 | + | proxy.location = locationProxy; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | try { | |
| 137 | + | Object.defineProperty(proxy, Symbol.toStringTag, { | |
| 138 | + | configurable: true, | |
| 139 | + | value: 'Window' | |
| 140 | + | }); | |
| 141 | + | } catch (_) {} | |
| 142 | + | ||
| 143 | + | return proxy; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | try { | |
| 147 | + | var originalWindowOpen = typeof window.open === 'function' ? window.open : null; | |
| 148 | + | if (!originalWindowOpen) return; | |
| 149 | + | ||
| 150 | + | window.__DUMBER_POPUP_PATCH_FLAG__ = true; | |
| 151 | + | window.open = function(url, target, features) { | |
| 152 | + | var normalizedTarget = target == null ? '' : String(target); | |
| 153 | + | if (shouldDelegateWindowOpen(normalizedTarget)) { | |
| 154 | + | return originalWindowOpen.apply(this, arguments); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | var proxyID = createPopupProxyID(); | |
| 158 | + | var resolvedURL = resolvePopupURL(url); | |
| 159 | + | var noJavaScriptAccess = popupHasNoOpener(features); | |
| 160 | + | var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features); | |
| 161 | + | dispatchPopupOpen({ | |
| 162 | + | proxy_id: proxyID, | |
| 163 | + | url: resolvedURL, | |
| 164 | + | frame_name: normalizedTarget, | |
| 165 | + | user_gesture: hasUserGesture(), | |
| 166 | + | no_javascript_access: noJavaScriptAccess | |
| 167 | + | }); | |
| 168 | + | return popupProxy; | |
| 169 | + | }; | |
| 170 | + | } catch (_) { | |
| 171 | + | window.__DUMBER_POPUP_PATCH_FLAG__ = false; | |
| 172 | + | } | |
| 173 | + | ` | |
| 174 | + | ||
| 175 | + | const popupFetchBridgeHelpersJSTemplate = ` | |
| 176 | + | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | |
| 177 | + | ||
| 178 | + | function encodeBody(body) { | |
| 179 | + | try { | |
| 180 | + | return btoa(unescape(encodeURIComponent(body))); | |
| 181 | + | } catch (_) { | |
| 182 | + | return ''; | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | function postBridge(url, payload) { | |
| 187 | + | var encoded = encodeBody(JSON.stringify(payload)); | |
| 188 | + | if (!encoded) return; | |
| 189 | + | fetch(url, { | |
| 190 | + | method: 'POST', | |
| 191 | + | headers: { | |
| 192 | + | 'X-Dumber-Body': encoded, | |
| 193 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 194 | + | } | |
| 195 | + | }).catch(function() {}); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | function dispatchPopupOpen(payload) { | |
| 199 | + | postBridge('dumb:///api/popup-open', payload); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | function dispatchPopupNavigate(proxyID, url) { | |
| 203 | + | postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: url }); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | function dispatchPopupClose(proxyID) { | |
| 207 | + | postBridge('dumb:///api/popup-close', { proxy_id: proxyID }); | |
| 208 | + | } | |
| 209 | + | ` | |
| 210 | + | ||
| 211 | + | const rendererPopupDispatchHelpersJS = ` | |
| 212 | + | function dispatchPopupOpen(payload) { | |
| 213 | + | send('popup_open', JSON.stringify(payload)); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | function dispatchPopupNavigate(proxyID, url) { | |
| 217 | + | send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: url })); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | function dispatchPopupClose(proxyID) { | |
| 221 | + | send('popup_close', JSON.stringify({ proxy_id: proxyID })); | |
| 222 | + | } | |
| 223 | + | ` | |
| 224 | + | ||
| 225 | + | func buildPopupProxyBridgeBodyJS(dispatch popupProxyBridgeDispatch) string { | |
| 226 | + | replacer := strings.NewReplacer( | |
| 227 | + | "__DUMBER_POPUP_PATCH_FLAG__", dispatch.patchFlag, | |
| 228 | + | "__DUMBER_POPUP_DISPATCH_HELPERS__", dispatch.helpers, | |
| 229 | + | ) | |
| 230 | + | return replacer.Replace(popupProxyBridgeBodyTemplateJS) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func wrapSelfExecutingScript(body string) string { | |
| 234 | + | return fmt.Sprintf("(function() {\n%s\n})();", body) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func buildTrustedPageFetchBridgeBaseJS() string { | |
| 238 | + | popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{ | |
| 239 | + | patchFlag: "__dumberPopupBridgePatched", | |
| 240 | + | helpers: popupFetchBridgeHelpersJSTemplate, | |
| 241 | + | })) | |
| 242 | + | return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | const bridgeNoncePlaceholder = "__DUMBER_BRIDGE_NONCE__" | |
| 246 | + | ||
| 247 | + | func buildTrustedPageFetchBridgeJS(bridgeNonce string) string { | |
| 248 | + | if strings.Contains(bridgeNonce, bridgeNoncePlaceholder) { | |
| 249 | + | panic("bridge nonce contains reserved placeholder") | |
| 250 | + | } | |
| 251 | + | return strings.ReplaceAll(trustedPageFetchBridgeBaseJS, bridgeNoncePlaceholder, bridgeNonce) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func buildRendererBridgeExtensionJS() string { | |
| 255 | + | popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{ | |
| 256 | + | patchFlag: "__dumberPopupOpenPatched", | |
| 257 | + | helpers: rendererPopupDispatchHelpersJS, | |
| 258 | + | }) | |
| 259 | + | return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string { | |
| 263 | + | replacer := strings.NewReplacer( | |
| 264 | + | "__DUMBER_BRIDGE_NONCE__", webutil.EscapeForJSString(bridgeNonce), | |
| 265 | + | "__DUMBER_PARENT_URI__", webutil.EscapeForJSString(parentURI), | |
| 266 | + | ) | |
| 267 | + | return replacer.Replace(popupOpenerBridgeTemplateJS) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // clipboardSelectionFetchBridgeJSTemplate keeps the lightweight fetch bridge | |
| 271 | + | // enabled while the native renderer bridge remains disabled due to an OSR | |
| 272 | + | // startup regression. It mirrors explicit clipboard text from DOM selections, | |
| 273 | + | // safe input/textarea selections, and async Clipboard API writes, and also | |
| 274 | + | // reasserts browser focus when an editable element gains DOM focus. | |
| 275 | + | const clipboardSelectionFetchBridgeJSTemplate = `(function(){ | |
| 276 | + | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | |
| 277 | + | ||
| 278 | + | function encodeBody(body) { | |
| 279 | + | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | function inputType(node) { | |
| 283 | + | return node && node.type ? String(node.type).toLowerCase() : 'text'; | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | function isTextEditableInputType(type) { | |
| 287 | + | switch ((type || 'text').toLowerCase()) { | |
| 288 | + | case 'button': | |
| 289 | + | case 'submit': | |
| 290 | + | case 'reset': | |
| 291 | + | case 'checkbox': | |
| 292 | + | case 'radio': | |
| 293 | + | case 'range': | |
| 294 | + | case 'color': | |
| 295 | + | case 'file': | |
| 296 | + | case 'image': | |
| 297 | + | case 'hidden': | |
| 298 | + | return false; | |
| 299 | + | default: | |
| 300 | + | return true; | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | function isSelectionReadableInputType(type) { | |
| 305 | + | return isTextEditableInputType(type) && type !== 'password'; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | function getActiveElementSelection() { | |
| 309 | + | var el = document.activeElement; | |
| 310 | + | if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return ''; | |
| 311 | + | var tag = el.tagName; | |
| 312 | + | if (el.disabled || el.readOnly) return ''; | |
| 313 | + | if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return ''; | |
| 314 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return ''; | |
| 315 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |
| 316 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |
| 317 | + | if (end > start) return el.value.slice(start, end); | |
| 318 | + | return ''; | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | function getSelectedText() { | |
| 322 | + | var activeSelection = getActiveElementSelection(); | |
| 323 | + | if (activeSelection) return activeSelection; | |
| 324 | + | var sel = window.getSelection(); | |
| 325 | + | return sel ? sel.toString() : ''; | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | function isEditable(node) { | |
| 329 | + | if (!node || node.nodeType !== 1) return false; | |
| 330 | + | if (node.isContentEditable) return true; | |
| 331 | + | var tag = node.tagName; | |
| 332 | + | if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly; | |
| 333 | + | if (tag !== 'INPUT') return false; | |
| 334 | + | if (node.disabled || node.readOnly) return false; | |
| 335 | + | return isTextEditableInputType(inputType(node)); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | function sendToClipboard(text) { | |
| 339 | + | if (!text) return; | |
| 340 | + | var body = JSON.stringify({text: text}); | |
| 341 | + | var encoded = encodeBody(body); | |
| 342 | + | if (!encoded) return; | |
| 343 | + | fetch('dumb:///api/clipboard-set', { | |
| 344 | + | method: 'POST', | |
| 345 | + | headers: { | |
| 346 | + | 'X-Dumber-Body': encoded, | |
| 347 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 348 | + | } | |
| 349 | + | }).catch(function(){}); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | function sendFocusSync() { | |
| 353 | + | fetch('dumb:///api/focus-sync', { | |
| 354 | + | method: 'POST', | |
| 355 | + | headers: { | |
| 356 | + | 'X-Dumber-Bridge-Action': 'focus-sync', | |
| 357 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 358 | + | } | |
| 359 | + | }).catch(function(){}); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | function mirrorClipboardItems(items) { | |
| 363 | + | try { | |
| 364 | + | if (!items || typeof items.length !== 'number') return; | |
| 365 | + | Array.prototype.forEach.call(items, function(item) { | |
| 366 | + | if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return; | |
| 367 | + | Promise.resolve(item.getType('text/plain')) | |
| 368 | + | .then(function(blob) { | |
| 369 | + | if (!blob || typeof blob.text !== 'function') return ''; | |
| 370 | + | return blob.text(); | |
| 371 | + | }) | |
| 372 | + | .then(function(text) { | |
| 373 | + | if (text) sendToClipboard(text); | |
| 374 | + | }) | |
| 375 | + | .catch(function(){}); | |
| 376 | + | }); | |
| 377 | + | } catch (_) {} | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | document.addEventListener('copy', function(event) { | |
| 381 | + | if (event && event.isTrusted === false) return; | |
| 382 | + | var text = getSelectedText(); | |
| 383 | + | if (text) sendToClipboard(text); | |
| 384 | + | }); | |
| 385 | + | document.addEventListener('cut', function(event) { | |
| 386 | + | if (event && event.isTrusted === false) return; | |
| 387 | + | var text = getSelectedText(); | |
| 388 | + | if (text) sendToClipboard(text); | |
| 389 | + | }); | |
| 390 | + | document.addEventListener('focusin', function(event) { | |
| 391 | + | if (event && event.isTrusted === false) return; | |
| 392 | + | if (isEditable(event && event.target)) sendFocusSync(); | |
| 393 | + | }, true); | |
| 394 | + | ||
| 395 | + | try { | |
| 396 | + | var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null; | |
| 397 | + | var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) || | |
| 398 | + | (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null); | |
| 399 | + | ||
| 400 | + | if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) { | |
| 401 | + | clipboardProto.__dumberWriteTextPatched = true; | |
| 402 | + | var originalProtoWriteText = clipboardProto.writeText; | |
| 403 | + | var wrappedProtoWriteText = function(text) { | |
| 404 | + | var normalized = text == null ? '' : String(text); | |
| 405 | + | var result = originalProtoWriteText.call(this, normalized); | |
| 406 | + | if (result && typeof result.then === 'function') { | |
| 407 | + | return result.then(function(value) { | |
| 408 | + | if (normalized) sendToClipboard(normalized); | |
| 409 | + | return value; | |
| 410 | + | }); | |
| 411 | + | } | |
| 412 | + | if (normalized) sendToClipboard(normalized); | |
| 413 | + | return result; | |
| 414 | + | }; | |
| 415 | + | try { | |
| 416 | + | Object.defineProperty(clipboardProto, 'writeText', { | |
| 417 | + | configurable: true, | |
| 418 | + | writable: true, | |
| 419 | + | value: wrappedProtoWriteText | |
| 420 | + | }); | |
| 421 | + | } catch (_) { | |
| 422 | + | clipboardProto.writeText = wrappedProtoWriteText; | |
| 423 | + | } | |
| 424 | + | } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) { | |
| 425 | + | window.__dumberClipboardWriteTextPatched = true; | |
| 426 | + | var originalWriteText = clipboardObj.writeText.bind(clipboardObj); | |
| 427 | + | var wrappedWriteText = function(text) { | |
| 428 | + | var normalized = text == null ? '' : String(text); | |
| 429 | + | var result = originalWriteText(normalized); | |
| 430 | + | if (result && typeof result.then === 'function') { | |
| 431 | + | return result.then(function(value) { | |
| 432 | + | if (normalized) sendToClipboard(normalized); | |
| 433 | + | return value; | |
| 434 | + | }); | |
| 435 | + | } | |
| 436 | + | if (normalized) sendToClipboard(normalized); | |
| 437 | + | return result; | |
| 438 | + | }; | |
| 439 | + | try { | |
| 440 | + | Object.defineProperty(clipboardObj, 'writeText', { | |
| 441 | + | configurable: true, | |
| 442 | + | writable: true, | |
| 443 | + | value: wrappedWriteText | |
| 444 | + | }); | |
| 445 | + | } catch (_) { | |
| 446 | + | clipboardObj.writeText = wrappedWriteText; | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) { | |
| 451 | + | clipboardProto.__dumberWritePatched = true; | |
| 452 | + | var originalProtoWrite = clipboardProto.write; | |
| 453 | + | var wrappedProtoWrite = function(items) { | |
| 454 | + | var result = originalProtoWrite.apply(this, arguments); | |
| 455 | + | if (result && typeof result.then === 'function') { | |
| 456 | + | return result.then(function(value) { | |
| 457 | + | mirrorClipboardItems(items); | |
| 458 | + | return value; | |
| 459 | + | }); | |
| 460 | + | } | |
| 461 | + | mirrorClipboardItems(items); | |
| 462 | + | return result; | |
| 463 | + | }; | |
| 464 | + | try { | |
| 465 | + | Object.defineProperty(clipboardProto, 'write', { | |
| 466 | + | configurable: true, | |
| 467 | + | writable: true, | |
| 468 | + | value: wrappedProtoWrite | |
| 469 | + | }); | |
| 470 | + | } catch (_) { | |
| 471 | + | clipboardProto.write = wrappedProtoWrite; | |
| 472 | + | } | |
| 473 | + | } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) { | |
| 474 | + | window.__dumberClipboardWritePatched = true; | |
| 475 | + | var originalWrite = clipboardObj.write.bind(clipboardObj); | |
| 476 | + | var wrappedWrite = function(items) { | |
| 477 | + | var result = originalWrite(items); | |
| 478 | + | if (result && typeof result.then === 'function') { | |
| 479 | + | return result.then(function(value) { | |
| 480 | + | mirrorClipboardItems(items); | |
| 481 | + | return value; | |
| 482 | + | }); | |
| 483 | + | } | |
| 484 | + | mirrorClipboardItems(items); | |
| 485 | + | return result; | |
| 486 | + | }; | |
| 487 | + | try { | |
| 488 | + | Object.defineProperty(clipboardObj, 'write', { | |
| 489 | + | configurable: true, | |
| 490 | + | writable: true, | |
| 491 | + | value: wrappedWrite | |
| 492 | + | }); | |
| 493 | + | } catch (_) { | |
| 494 | + | clipboardObj.write = wrappedWrite; | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | } catch (_) {} | |
| 498 | + | ||
| 499 | + | if (isEditable(document.activeElement)) sendFocusSync(); | |
| 500 | + | })();` | |
| 501 | + | ||
| 502 | + | const rendererBridgeExtensionTemplateJS = ` | |
| 503 | + | (function() { | |
| 504 | + | native function Dispatch(action, payload); | |
| 505 | + | ||
| 506 | + | function send(action, payload) { | |
| 507 | + | return Dispatch(action, payload == null ? '' : String(payload)); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | function isEditable(node) { | |
| 511 | + | if (!node || node.nodeType !== 1) return false; | |
| 512 | + | if (node.isContentEditable) return true; | |
| 513 | + | var tag = node.tagName; | |
| 514 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false; | |
| 515 | + | if (node.disabled || node.readOnly) return false; | |
| 516 | + | return true; | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | function getActiveElementSelection() { | |
| 520 | + | var el = document.activeElement; | |
| 521 | + | if (!el) return ''; | |
| 522 | + | var tag = el.tagName; | |
| 523 | + | if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') { | |
| 524 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |
| 525 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |
| 526 | + | if (end > start) return el.value.slice(start, end); | |
| 527 | + | } | |
| 528 | + | return ''; | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | function getSelectedText() { | |
| 532 | + | var activeSelection = getActiveElementSelection(); | |
| 533 | + | if (activeSelection) return activeSelection; | |
| 534 | + | var sel = window.getSelection(); | |
| 535 | + | return sel ? sel.toString() : ''; | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | function notifyExplicitTextCopy(action, text) { | |
| 539 | + | var normalizedText = text == null ? '' : String(text); | |
| 540 | + | var normalizedAction = action == null || action === '' ? 'copy' : String(action); | |
| 541 | + | return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction })); | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | __DUMBER_POPUP_PROXY_BRIDGE_BODY__ | |
| 545 | + | ||
| 546 | + | if (typeof document !== 'undefined' && document.addEventListener) { | |
| 547 | + | document.addEventListener('focusin', function(event) { | |
| 548 | + | if (isEditable(event.target)) { | |
| 549 | + | send('focus_sync', ''); | |
| 550 | + | } | |
| 551 | + | }, true); | |
| 552 | + | ||
| 553 | + | function mirrorClipboardEvent(action, e) { | |
| 554 | + | if (!e.isTrusted) return; | |
| 555 | + | var capturedText = ''; | |
| 556 | + | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |
| 557 | + | try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {} | |
| 558 | + | } | |
| 559 | + | setTimeout(function() { | |
| 560 | + | var text = capturedText; | |
| 561 | + | if (!text && !e.defaultPrevented) text = getSelectedText(); | |
| 562 | + | if (text) notifyExplicitTextCopy(action, text); | |
| 563 | + | }, 0); | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | document.addEventListener('copy', function(e) { | |
| 567 | + | mirrorClipboardEvent('copy', e); | |
| 568 | + | }, true); | |
| 569 | + | document.addEventListener('cut', function(e) { | |
| 570 | + | mirrorClipboardEvent('cut', e); | |
| 571 | + | }, true); | |
| 572 | + | } | |
| 573 | + | try { | |
| 574 | + | var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null; | |
| 575 | + | var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) || | |
| 576 | + | (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null); | |
| 577 | + | ||
| 578 | + | function mirrorClipboardItems(items) { | |
| 579 | + | try { | |
| 580 | + | if (!items || typeof items.length !== 'number') return; | |
| 581 | + | Array.prototype.forEach.call(items, function(item) { | |
| 582 | + | if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return; | |
| 583 | + | Promise.resolve(item.getType('text/plain')) | |
| 584 | + | .then(function(blob) { | |
| 585 | + | if (!blob || typeof blob.text !== 'function') return ''; | |
| 586 | + | return blob.text(); | |
| 587 | + | }) | |
| 588 | + | .then(function(text) { | |
| 589 | + | if (text) notifyExplicitTextCopy('copy', text); | |
| 590 | + | }) | |
| 591 | + | .catch(function() {}); | |
| 592 | + | }); | |
| 593 | + | } catch (_) {} | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) { | |
| 597 | + | clipboardProto.__dumberWriteTextPatched = true; | |
| 598 | + | var originalProtoWriteText = clipboardProto.writeText; | |
| 599 | + | var wrappedProtoWriteText = function(text) { | |
| 600 | + | var normalized = text == null ? '' : String(text); | |
| 601 | + | var result = originalProtoWriteText.call(this, normalized); | |
| 602 | + | if (result && typeof result.then === 'function') { | |
| 603 | + | return result.then(function(value) { | |
| 604 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 605 | + | return value; | |
| 606 | + | }); | |
| 607 | + | } | |
| 608 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 609 | + | return result; | |
| 610 | + | }; | |
| 611 | + | try { | |
| 612 | + | Object.defineProperty(clipboardProto, 'writeText', { | |
| 613 | + | configurable: true, | |
| 614 | + | writable: true, | |
| 615 | + | value: wrappedProtoWriteText | |
| 616 | + | }); | |
| 617 | + | } catch (_) { | |
| 618 | + | clipboardProto.writeText = wrappedProtoWriteText; | |
| 619 | + | } | |
| 620 | + | } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) { | |
| 621 | + | window.__dumberClipboardWriteTextPatched = true; | |
| 622 | + | var originalWriteText = clipboardObj.writeText.bind(clipboardObj); | |
| 623 | + | var wrappedWriteText = function(text) { | |
| 624 | + | var normalized = text == null ? '' : String(text); | |
| 625 | + | var result = originalWriteText(normalized); | |
| 626 | + | if (result && typeof result.then === 'function') { | |
| 627 | + | return result.then(function(value) { | |
| 628 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 629 | + | return value; | |
| 630 | + | }); | |
| 631 | + | } | |
| 632 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 633 | + | return result; | |
| 634 | + | }; | |
| 635 | + | try { | |
| 636 | + | Object.defineProperty(clipboardObj, 'writeText', { | |
| 637 | + | configurable: true, | |
| 638 | + | writable: true, | |
| 639 | + | value: wrappedWriteText | |
| 640 | + | }); | |
| 641 | + | } catch (_) { | |
| 642 | + | clipboardObj.writeText = wrappedWriteText; | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) { | |
| 647 | + | clipboardProto.__dumberWritePatched = true; | |
| 648 | + | var originalProtoWrite = clipboardProto.write; | |
| 649 | + | var wrappedProtoWrite = function(items) { | |
| 650 | + | var result = originalProtoWrite.apply(this, arguments); | |
| 651 | + | if (result && typeof result.then === 'function') { | |
| 652 | + | return result.then(function(value) { | |
| 653 | + | mirrorClipboardItems(items); | |
| 654 | + | return value; | |
| 655 | + | }); | |
| 656 | + | } | |
| 657 | + | mirrorClipboardItems(items); | |
| 658 | + | return result; | |
| 659 | + | }; | |
| 660 | + | try { | |
| 661 | + | Object.defineProperty(clipboardProto, 'write', { | |
| 662 | + | configurable: true, | |
| 663 | + | writable: true, | |
| 664 | + | value: wrappedProtoWrite | |
| 665 | + | }); | |
| 666 | + | } catch (_) { | |
| 667 | + | clipboardProto.write = wrappedProtoWrite; | |
| 668 | + | } | |
| 669 | + | } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) { | |
| 670 | + | window.__dumberClipboardWritePatched = true; | |
| 671 | + | var originalWrite = clipboardObj.write.bind(clipboardObj); | |
| 672 | + | var wrappedWrite = function(items) { | |
| 673 | + | var result = originalWrite(items); | |
| 674 | + | if (result && typeof result.then === 'function') { | |
| 675 | + | return result.then(function(value) { | |
| 676 | + | mirrorClipboardItems(items); | |
| 677 | + | return value; | |
| 678 | + | }); | |
| 679 | + | } | |
| 680 | + | mirrorClipboardItems(items); | |
| 681 | + | return result; | |
| 682 | + | }; | |
| 683 | + | try { | |
| 684 | + | Object.defineProperty(clipboardObj, 'write', { | |
| 685 | + | configurable: true, | |
| 686 | + | writable: true, | |
| 687 | + | value: wrappedWrite | |
| 688 | + | }); | |
| 689 | + | } catch (_) { | |
| 690 | + | clipboardObj.write = wrappedWrite; | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | } catch (_) {} | |
| 694 | + | ||
| 695 | + | send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : ''); | |
| 696 | + | })(); | |
| 697 | + | ` | |
| 698 | + | ||
| 699 | + | const popupOpenerBridgeTemplateJS = `(function() { | |
| 700 | + | if (typeof window === 'undefined') return; | |
| 701 | + | if (window.__dumberPopupOpenerBridgeInstalled) return; | |
| 702 | + | window.__dumberPopupOpenerBridgeInstalled = true; | |
| 703 | + | if (window.opener != null) return; | |
| 704 | + | ||
| 705 | + | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | |
| 706 | + | var openerHref = '__DUMBER_PARENT_URI__'; | |
| 707 | + | ||
| 708 | + | function postBridge(path, payload) { | |
| 709 | + | try { | |
| 710 | + | fetch('dumb:///api/' + path, { | |
| 711 | + | method: 'POST', | |
| 712 | + | headers: { | |
| 713 | + | 'Content-Type': 'application/json', | |
| 714 | + | 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))), | |
| 715 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 716 | + | } | |
| 717 | + | }).catch(function() {}); | |
| 718 | + | } catch (_) {} | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | function normalizeURLValue(rawURL) { | |
| 722 | + | if (rawURL == null) return ''; | |
| 723 | + | try { | |
| 724 | + | return String(rawURL); | |
| 725 | + | } catch (_) { | |
| 726 | + | return ''; | |
| 727 | + | } | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | function navigateOpener(nextURL) { | |
| 731 | + | var rawURL = normalizeURLValue(nextURL); | |
| 732 | + | if (rawURL !== '') openerHref = rawURL; | |
| 733 | + | postBridge('popup-opener-navigate', { url: rawURL }); | |
| 734 | + | return openerHref; | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | function serializeMessage(value) { | |
| 738 | + | try { | |
| 739 | + | return { kind: 'json', value: JSON.stringify(value) }; | |
| 740 | + | } catch (_) { | |
| 741 | + | return { kind: 'string', value: String(value) }; | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | var locationProxy = { | |
| 746 | + | assign: function(nextURL) { return navigateOpener(nextURL); }, | |
| 747 | + | replace: function(nextURL) { return navigateOpener(nextURL); }, | |
| 748 | + | toString: function() { return openerHref; } | |
| 749 | + | }; | |
| 750 | + | try { | |
| 751 | + | Object.defineProperty(locationProxy, 'href', { | |
| 752 | + | configurable: true, | |
| 753 | + | enumerable: true, | |
| 754 | + | get: function() { return openerHref; }, | |
| 755 | + | set: function(nextURL) { navigateOpener(nextURL); } | |
| 756 | + | }); | |
| 757 | + | } catch (_) { | |
| 758 | + | locationProxy.href = openerHref; | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | var openerProxy = { | |
| 762 | + | blur: function() { return undefined; }, | |
| 763 | + | close: function() { return undefined; }, | |
| 764 | + | focus: function() { return undefined; }, | |
| 765 | + | postMessage: function(message, targetOrigin) { | |
| 766 | + | var serialized = serializeMessage(message); | |
| 767 | + | postBridge('popup-opener-post-message', { | |
| 768 | + | data: serialized.value, | |
| 769 | + | data_kind: serialized.kind, | |
| 770 | + | target_origin: targetOrigin == null ? '*' : String(targetOrigin), | |
| 771 | + | source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '', | |
| 772 | + | source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : '' | |
| 773 | + | }); | |
| 774 | + | return undefined; | |
| 775 | + | } | |
| 776 | + | }; | |
| 777 | + | openerProxy.self = openerProxy; | |
| 778 | + | openerProxy.window = openerProxy; | |
| 779 | + | try { | |
| 780 | + | Object.defineProperty(openerProxy, 'closed', { | |
| 781 | + | configurable: true, | |
| 782 | + | enumerable: true, | |
| 783 | + | get: function() { return false; } | |
| 784 | + | }); | |
| 785 | + | } catch (_) { | |
| 786 | + | openerProxy.closed = false; | |
| 787 | + | } | |
| 788 | + | try { | |
| 789 | + | Object.defineProperty(openerProxy, 'location', { | |
| 790 | + | configurable: true, | |
| 791 | + | enumerable: true, | |
| 792 | + | get: function() { return locationProxy; }, | |
| 793 | + | set: function(nextURL) { navigateOpener(nextURL); } | |
| 794 | + | }); | |
| 795 | + | } catch (_) { | |
| 796 | + | openerProxy.location = locationProxy; | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | try { | |
| 800 | + | Object.defineProperty(window, 'opener', { | |
| 801 | + | configurable: true, | |
| 802 | + | enumerable: true, | |
| 803 | + | get: function() { return openerProxy; } | |
| 804 | + | }); | |
| 805 | + | } catch (_) { | |
| 806 | + | try { window.opener = openerProxy; } catch (_) {} | |
| 807 | + | } | |
| 808 | + | })();` | |
M internal/infrastructure/cef/content_injector.go +12 -29
| @@ -385,33 +385,6 @@ const redditDirectVideoJS = `(function(){ | |||
| 385 | 385 | }, 1000); | |
| 386 | 386 | })();` | |
| 387 | 387 | ||
| 388 | - | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |
| 389 | - | // back to Go via the message bridge so it can be written to the GDK clipboard. | |
| 390 | - | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |
| 391 | - | // not the Wayland/X11 system clipboard. | |
| 392 | - | const clipboardCopyBridgeJS = `(function(){ | |
| 393 | - | if (window.__dumberClipboardBridge) return; | |
| 394 | - | window.__dumberClipboardBridge = true; | |
| 395 | - | function sendToClipboard(text) { | |
| 396 | - | if (!text) return; | |
| 397 | - | var body = JSON.stringify({text: text}); | |
| 398 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 399 | - | if (!encoded) return; | |
| 400 | - | fetch('dumb:///api/clipboard-set', { | |
| 401 | - | method: 'POST', | |
| 402 | - | headers: {'X-Dumber-Body': encoded} | |
| 403 | - | }).catch(function(){}); | |
| 404 | - | } | |
| 405 | - | document.addEventListener('copy', function() { | |
| 406 | - | var sel = window.getSelection(); | |
| 407 | - | if (sel) sendToClipboard(sel.toString()); | |
| 408 | - | }); | |
| 409 | - | document.addEventListener('cut', function() { | |
| 410 | - | var sel = window.getSelection(); | |
| 411 | - | if (sel) sendToClipboard(sel.toString()); | |
| 412 | - | }); | |
| 413 | - | })();` | |
| 414 | - | ||
| 415 | 388 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 416 | 389 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 417 | 390 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -532,8 +505,18 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 532 | 505 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 533 | 506 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 534 | 507 | ||
| 535 | - | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | |
| 536 | - | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | |
| 508 | + | // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still | |
| 509 | + | // need a JS bridge in OSR mode while the native renderer bridge remains disabled. | |
| 510 | + | bridgeNonce := wv.ensureBridgeNonce() | |
| 511 | + | if bridgeNonce == "" { | |
| 512 | + | ctx := context.Background() | |
| 513 | + | if ci != nil && ci.engine != nil { | |
| 514 | + | ctx = ci.engine.currentContext() | |
| 515 | + | } | |
| 516 | + | logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed") | |
| 517 | + | } else { | |
| 518 | + | wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce)) | |
| 519 | + | } | |
| 537 | 520 | ||
| 538 | 521 | // Video playback diagnostic — logs video element state changes. | |
| 539 | 522 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
A internal/infrastructure/cef/content_injector_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func testTrustedPageFetchBridgeJS() string { | |
| 11 | + | return buildTrustedPageFetchBridgeJS("test-bridge-nonce") | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) { | |
| 15 | + | script := testTrustedPageFetchBridgeJS() | |
| 16 | + | ||
| 17 | + | require.Contains(t, script, "document.activeElement") | |
| 18 | + | require.Contains(t, script, "selectionStart") | |
| 19 | + | require.Contains(t, script, "selectionEnd") | |
| 20 | + | require.Contains(t, script, "window.getSelection()") | |
| 21 | + | require.Contains(t, script, "type !== 'password'") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) { | |
| 25 | + | script := testTrustedPageFetchBridgeJS() | |
| 26 | + | ||
| 27 | + | require.Contains(t, script, "dumb:///api/focus-sync") | |
| 28 | + | require.Contains(t, script, "X-Dumber-Bridge-Action") | |
| 29 | + | require.Contains(t, script, "X-Dumber-Bridge-Nonce") | |
| 30 | + | require.Contains(t, script, "focus-sync") | |
| 31 | + | require.Contains(t, script, "focusin") | |
| 32 | + | require.Contains(t, script, "event.isTrusted === false") | |
| 33 | + | require.Contains(t, script, "event && event.target") | |
| 34 | + | require.Contains(t, script, "document.activeElement") | |
| 35 | + | require.Contains(t, script, "isEditable(document.activeElement)") | |
| 36 | + | require.Contains(t, script, "sendFocusSync()") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) { | |
| 40 | + | script := testTrustedPageFetchBridgeJS() | |
| 41 | + | ||
| 42 | + | require.Contains(t, script, "dumb:///api/clipboard-set") | |
| 43 | + | require.Contains(t, script, "X-Dumber-Body") | |
| 44 | + | require.Contains(t, script, "X-Dumber-Bridge-Nonce") | |
| 45 | + | require.Contains(t, script, "document.addEventListener('copy', function(event)") | |
| 46 | + | require.Contains(t, script, "document.addEventListener('cut', function(event)") | |
| 47 | + | require.Contains(t, script, "event.isTrusted === false") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) { | |
| 51 | + | script := testTrustedPageFetchBridgeJS() | |
| 52 | + | ||
| 53 | + | require.Contains(t, script, "navigator && navigator.clipboard") | |
| 54 | + | require.Contains(t, script, "clipboardProto.writeText") | |
| 55 | + | require.Contains(t, script, "clipboardObj.writeText") | |
| 56 | + | require.Contains(t, script, "clipboardProto.write") | |
| 57 | + | require.Contains(t, script, "clipboardObj.write") | |
| 58 | + | require.Contains(t, script, "sendToClipboard(normalized)") | |
| 59 | + | require.Contains(t, script, "mirrorClipboardItems(items)") | |
| 60 | + | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | |
| 61 | + | require.NotContains(t, script, "__dumberClipboardBridge") | |
| 62 | + | require.NotContains(t, script, "setBridgeNonce") | |
| 63 | + | require.Contains(t, script, "test-bridge-nonce") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) { | |
| 67 | + | script := testTrustedPageFetchBridgeJS() | |
| 68 | + | ||
| 69 | + | require.Contains(t, script, "window.__dumberPopupBridgePatched") | |
| 70 | + | require.Contains(t, script, "window.open = function(url, target, features)") | |
| 71 | + | require.Contains(t, script, "dumb:///api/popup-open") | |
| 72 | + | require.Contains(t, script, "dumb:///api/popup-navigate") | |
| 73 | + | require.Contains(t, script, "dumb:///api/popup-close") | |
| 74 | + | require.Contains(t, script, "proxy_id") | |
| 75 | + | require.Contains(t, script, "createSyntheticPopupProxy") | |
| 76 | + | require.Contains(t, script, "createPopupProxyID") | |
| 77 | + | require.Contains(t, script, "crypto.getRandomValues") | |
| 78 | + | require.Contains(t, script, "no_javascript_access") | |
| 79 | + | require.Contains(t, script, "Object.defineProperty(proxy, 'closed'") | |
| 80 | + | require.Contains(t, script, "test-bridge-nonce") | |
| 81 | + | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestPopupOpenerBridgeScript_InstallsSyntheticOpenerCallbacks(t *testing.T) { | |
| 85 | + | parent := &WebView{ctx: context.Background()} | |
| 86 | + | parent.updateURI("https://example.com/app") | |
| 87 | + | popup := &WebView{ctx: context.Background(), popupOpenerBridgeParent: parent, popupOpenerBridgeParentURI: "https://example.com/app"} | |
| 88 | + | ||
| 89 | + | script := popup.popupOpenerBridgeScript("bridge-nonce") | |
| 90 | + | ||
| 91 | + | require.Contains(t, script, "window.__dumberPopupOpenerBridgeInstalled") | |
| 92 | + | require.Contains(t, script, "popup-opener-navigate") | |
| 93 | + | require.Contains(t, script, "popup-opener-post-message") | |
| 94 | + | require.Contains(t, script, "Object.defineProperty(window, 'opener'") | |
| 95 | + | require.Contains(t, script, "https://example.com/app") | |
| 96 | + | require.Contains(t, script, "bridge-nonce") | |
| 97 | + | } | |
M internal/infrastructure/cef/context_menu.go +281 -65
| @@ -1,18 +1,17 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 4 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 6 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | 13 | ) | |
| 8 | 14 | ||
| 9 | - | // menuEntry holds a snapshot of one CEF menu item. | |
| 10 | - | type menuEntry struct { | |
| 11 | - | label string | |
| 12 | - | commandID int32 | |
| 13 | - | isSep bool | |
| 14 | - | } | |
| 15 | - | ||
| 16 | 15 | // =========================================================================== | |
| 17 | 16 | // ContextMenuHandler (implemented on handlerSet) | |
| 18 | 17 | // =========================================================================== | |
| @@ -25,98 +24,315 @@ func (h *handlerSet) RunContextMenu( | |||
| 25 | 24 | params purecef.ContextMenuParams, model purecef.MenuModel, | |
| 26 | 25 | callback purecef.RunContextMenuCallback, | |
| 27 | 26 | ) int32 { | |
| 27 | + | if h == nil || callback == nil || model == nil { | |
| 28 | + | if callback != nil { | |
| 29 | + | callback.Cancel() | |
| 30 | + | } | |
| 31 | + | // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu. | |
| 32 | + | return 1 | |
| 33 | + | } | |
| 34 | + | if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil { | |
| 35 | + | // Returning 0 lets CEF show its native menu when no custom builder is wired. | |
| 36 | + | return 0 | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | menuContext := buildMenuContext(h.wv, params) | |
| 40 | + | items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext) | |
| 41 | + | if len(items) == 0 { | |
| 42 | + | return 0 | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Snapshot the command IDs from CEF — model/params pointers are only valid during this call. | |
| 28 | 46 | count := model.GetCount() | |
| 29 | 47 | if count == 0 { | |
| 30 | 48 | callback.Cancel() | |
| 31 | 49 | return 1 | |
| 32 | 50 | } | |
| 33 | - | ||
| 34 | - | // Snapshot menu items — model/params pointers are only valid during this call. | |
| 35 | - | items := make([]menuEntry, 0, count) | |
| 51 | + | commandIDsByLabel := make(map[string]int32, count) | |
| 36 | 52 | for i := range count { | |
| 37 | 53 | t := model.GetTypeAt(i) | |
| 38 | 54 | if t == purecef.MenuItemTypeMenuitemtypeSeparator { | |
| 39 | - | items = append(items, menuEntry{isSep: true}) | |
| 40 | 55 | continue | |
| 41 | 56 | } | |
| 42 | 57 | if t == purecef.MenuItemTypeMenuitemtypeCommand { | |
| 43 | - | items = append(items, menuEntry{ | |
| 44 | - | label: model.GetLabelAt(i), | |
| 45 | - | commandID: model.GetCommandIDAt(i), | |
| 46 | - | }) | |
| 58 | + | label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i))) | |
| 59 | + | if label != "" { | |
| 60 | + | commandIDsByLabel[label] = model.GetCommandIDAt(i) | |
| 61 | + | } | |
| 47 | 62 | } | |
| 48 | 63 | } | |
| 49 | - | if len(items) == 0 { | |
| 50 | - | callback.Cancel() | |
| 51 | - | return 1 | |
| 64 | + | commandIDByAction := make(map[port.MenuAction]int32, len(items)) | |
| 65 | + | for _, item := range items { | |
| 66 | + | if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok { | |
| 67 | + | commandIDByAction[item.Action] = cmdID | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil { | |
| 71 | + | return 0 | |
| 52 | 72 | } | |
| 53 | 73 | ||
| 54 | - | x := params.GetXcoord() | |
| 55 | - | y := params.GetYcoord() | |
| 74 | + | x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale) | |
| 75 | + | glArea := h.wv.pipeline.glArea | |
| 76 | + | logContextMenuPopupRequest(h, glArea, params, x, y) | |
| 77 | + | executor := h.contextMenuExecutor() | |
| 56 | 78 | ||
| 57 | - | h.wv.runOnGTK(func() { | |
| 58 | - | showContextMenu(h.wv, items, x, y, callback) | |
| 59 | - | }) | |
| 79 | + | NewRenderer(h.wv.runOnGTK).Show( | |
| 80 | + | items, | |
| 81 | + | &glArea.Widget, | |
| 82 | + | x, | |
| 83 | + | y, | |
| 84 | + | func(item port.MenuItem) { | |
| 85 | + | var copiedCallback func(string) | |
| 86 | + | if h.wv.engine != nil { | |
| 87 | + | copiedCallback = h.wv.engine.notifyClipboardCopied | |
| 88 | + | } | |
| 89 | + | dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext) | |
| 90 | + | }, | |
| 91 | + | func() { | |
| 92 | + | callback.Cancel() | |
| 93 | + | }, | |
| 94 | + | ) | |
| 60 | 95 | return 1 | |
| 61 | 96 | } | |
| 62 | 97 | ||
| 63 | - | func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) { | |
| 64 | - | glArea := wv.pipeline.glArea | |
| 65 | - | if glArea == nil { | |
| 66 | - | callback.Cancel() | |
| 67 | - | return | |
| 98 | + | func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor { | |
| 99 | + | if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil { | |
| 100 | + | return nil | |
| 68 | 101 | } | |
| 102 | + | return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor( | |
| 103 | + | h.wv.engine.clipboard, | |
| 104 | + | h.wv.engine.resolver, | |
| 105 | + | nil, | |
| 106 | + | &cefMenuDelegator{wv: h.wv}, | |
| 107 | + | ) | |
| 108 | + | } | |
| 69 | 109 | ||
| 70 | - | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 71 | - | box.AddCssClass("context-menu") | |
| 110 | + | func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) { | |
| 111 | + | if params == nil { | |
| 112 | + | return 0, 0 | |
| 113 | + | } | |
| 114 | + | if scale <= 0 { | |
| 115 | + | scale = 1 | |
| 116 | + | } | |
| 117 | + | return params.GetXcoord() / scale, params.GetYcoord() / scale | |
| 118 | + | } | |
| 72 | 119 | ||
| 73 | - | selected := false | |
| 74 | - | var popover *gtk.Popover | |
| 120 | + | func logContextMenuPopupRequest( | |
| 121 | + | h *handlerSet, | |
| 122 | + | glArea *gtk.GLArea, | |
| 123 | + | params purecef.ContextMenuParams, | |
| 124 | + | x, y int32, | |
| 125 | + | ) { | |
| 126 | + | if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | rawX, rawY := contextMenuRawPosition(params) | |
| 130 | + | parent := glArea.GetParent() | |
| 131 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 132 | + | Int32("raw_x", rawX). | |
| 133 | + | Int32("raw_y", rawY). | |
| 134 | + | Int32("popup_x", x). | |
| 135 | + | Int32("popup_y", y). | |
| 136 | + | Int32("scale", h.wv.pipeline.scale). | |
| 137 | + | Int("anchor_width", glArea.Widget.GetAllocatedWidth()). | |
| 138 | + | Int("anchor_height", glArea.Widget.GetAllocatedHeight()). | |
| 139 | + | Int("parent_width", cefWidgetAllocatedWidth(parent)). | |
| 140 | + | Int("parent_height", cefWidgetAllocatedHeight(parent)). | |
| 141 | + | Msg("cef: context menu popup request") | |
| 142 | + | } | |
| 75 | 143 | ||
| 76 | - | for _, item := range items { | |
| 77 | - | if item.isSep { | |
| 78 | - | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |
| 79 | - | box.Append(&sep.Widget) | |
| 80 | - | continue | |
| 81 | - | } | |
| 144 | + | func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) { | |
| 145 | + | if params == nil { | |
| 146 | + | return 0, 0 | |
| 147 | + | } | |
| 148 | + | return params.GetXcoord(), params.GetYcoord() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func cefWidgetAllocatedWidth(widget *gtk.Widget) int { | |
| 152 | + | if widget == nil { | |
| 153 | + | return 0 | |
| 154 | + | } | |
| 155 | + | return widget.GetAllocatedWidth() | |
| 156 | + | } | |
| 82 | 157 | ||
| 83 | - | btn := gtk.NewButton() | |
| 84 | - | btn.SetLabel(item.label) | |
| 85 | - | btn.AddCssClass("flat") | |
| 158 | + | func cefWidgetAllocatedHeight(widget *gtk.Widget) int { | |
| 159 | + | if widget == nil { | |
| 160 | + | return 0 | |
| 161 | + | } | |
| 162 | + | return widget.GetAllocatedHeight() | |
| 163 | + | } | |
| 86 | 164 | ||
| 87 | - | cmdID := item.commandID | |
| 88 | - | // clickCb captures popover by reference; this is safe because the | |
| 89 | - | // button cannot be clicked until after Popup() is called below, | |
| 90 | - | // at which point popover is fully initialized. | |
| 91 | - | clickCb := func(_ gtk.Button) { | |
| 92 | - | selected = true | |
| 165 | + | func dispatchContextMenuSelection( | |
| 166 | + | ctx context.Context, | |
| 167 | + | executor port.ContextMenuActionExecutor, | |
| 168 | + | callback purecef.RunContextMenuCallback, | |
| 169 | + | copiedCallback func(text string), | |
| 170 | + | commandIDByAction map[port.MenuAction]int32, | |
| 171 | + | item port.MenuItem, | |
| 172 | + | menuContext port.MenuContext, | |
| 173 | + | ) { | |
| 174 | + | log := logging.FromContext(ctx) | |
| 175 | + | if callback == nil { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | |
| 179 | + | if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" { | |
| 180 | + | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| 181 | + | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | |
| 93 | 182 | callback.Cont(cmdID, 0) | |
| 94 | - | if popover != nil { | |
| 95 | - | popover.Popdown() | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command") | |
| 186 | + | callback.Cancel() | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | |
| 190 | + | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | |
| 191 | + | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | |
| 192 | + | callback.Cancel() | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | if copiedCallback != nil { | |
| 196 | + | if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" { | |
| 197 | + | copiedCallback(copiedText) | |
| 96 | 198 | } | |
| 97 | 199 | } | |
| 98 | - | btn.ConnectClicked(&clickCb) | |
| 99 | - | box.Append(&btn.Widget) | |
| 200 | + | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | |
| 201 | + | callback.Cancel() | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| 205 | + | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | |
| 206 | + | callback.Cont(cmdID, 0) | |
| 207 | + | return | |
| 100 | 208 | } | |
| 209 | + | log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command") | |
| 210 | + | callback.Cancel() | |
| 211 | + | } | |
| 101 | 212 | ||
| 102 | - | popover = gtk.NewPopover() | |
| 103 | - | popover.SetChild(&box.Widget) | |
| 104 | - | popover.SetParent(&glArea.Widget) | |
| 105 | - | popover.SetHasArrow(false) | |
| 106 | - | popover.SetAutohide(true) | |
| 213 | + | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |
| 214 | + | switch action { | |
| 215 | + | case port.MenuActionCopySelection: | |
| 216 | + | return menuContext.SelectionText | |
| 217 | + | case port.MenuActionCopyLink: | |
| 218 | + | return menuContext.LinkURI | |
| 219 | + | default: | |
| 220 | + | return "" | |
| 221 | + | } | |
| 222 | + | } | |
| 107 | 223 | ||
| 108 | - | rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1} | |
| 109 | - | popover.SetPointingTo(rect) | |
| 224 | + | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |
| 225 | + | switch action { | |
| 226 | + | case port.MenuActionBack, | |
| 227 | + | port.MenuActionForward, | |
| 228 | + | port.MenuActionReload, | |
| 229 | + | port.MenuActionOpenLinkNewTab, | |
| 230 | + | port.MenuActionCopyLink, | |
| 231 | + | port.MenuActionCopyImage, | |
| 232 | + | port.MenuActionInspectElement, | |
| 233 | + | port.MenuActionCopySelection: | |
| 234 | + | return true | |
| 235 | + | default: | |
| 236 | + | return false | |
| 237 | + | } | |
| 238 | + | } | |
| 110 | 239 | ||
| 111 | - | closedCb := func(_ gtk.Popover) { | |
| 112 | - | if !selected { | |
| 113 | - | callback.Cancel() | |
| 240 | + | type cefMenuDelegator struct { | |
| 241 | + | wv *WebView | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 245 | + | if d == nil || d.wv == nil { | |
| 246 | + | return fmt.Errorf("cef menu delegator: webview not available") | |
| 247 | + | } | |
| 248 | + | switch action { | |
| 249 | + | case port.MenuActionBack: | |
| 250 | + | return d.wv.GoBack(ctx) | |
| 251 | + | case port.MenuActionForward: | |
| 252 | + | return d.wv.GoForward(ctx) | |
| 253 | + | case port.MenuActionReload: | |
| 254 | + | return d.wv.Reload(ctx) | |
| 255 | + | case port.MenuActionOpenLinkNewTab: | |
| 256 | + | if menuContext.LinkURI == "" { | |
| 257 | + | return fmt.Errorf("open link in new tab: link URI not available") | |
| 114 | 258 | } | |
| 115 | - | popover.Unparent() | |
| 259 | + | d.wv.mu.RLock() | |
| 260 | + | cb := d.wv.callbacks | |
| 261 | + | d.wv.mu.RUnlock() | |
| 262 | + | if cb == nil || cb.OnLinkMiddleClick == nil { | |
| 263 | + | return fmt.Errorf("open link in new tab: middle-click handler not available") | |
| 264 | + | } | |
| 265 | + | cb.OnLinkMiddleClick(menuContext.LinkURI) | |
| 266 | + | return nil | |
| 267 | + | case port.MenuActionInspectElement: | |
| 268 | + | d.wv.OpenDevTools() | |
| 269 | + | return nil | |
| 270 | + | case port.MenuActionCopySelection: | |
| 271 | + | d.wv.RunJavaScript(ctx, "document.execCommand('copy');") | |
| 272 | + | return nil | |
| 273 | + | default: | |
| 274 | + | return fmt.Errorf("cef menu delegator: unsupported action %s", action) | |
| 116 | 275 | } | |
| 117 | - | popover.ConnectClosed(&closedCb) | |
| 276 | + | } | |
| 118 | 277 | ||
| 119 | - | popover.Popup() | |
| 278 | + | func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) { | |
| 279 | + | for _, candidate := range contextMenuActionLabels(action, label) { | |
| 280 | + | if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok { | |
| 281 | + | return cmdID, true | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | return 0, false | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func contextMenuActionLabels(action port.MenuAction, label string) []string { | |
| 288 | + | labels := []string{label} | |
| 289 | + | switch action { | |
| 290 | + | case port.MenuActionBack: | |
| 291 | + | labels = append(labels, "Back") | |
| 292 | + | case port.MenuActionForward: | |
| 293 | + | labels = append(labels, "Forward") | |
| 294 | + | case port.MenuActionReload: | |
| 295 | + | labels = append(labels, "Reload") | |
| 296 | + | case port.MenuActionOpenLinkNewTab: | |
| 297 | + | labels = append(labels, "Open Link in New Tab", "Open link in new tab") | |
| 298 | + | case port.MenuActionCopyLink: | |
| 299 | + | labels = append(labels, "Copy Link", "Copy link address") | |
| 300 | + | case port.MenuActionCopyImage: | |
| 301 | + | labels = append(labels, "Copy Image", "Copy image") | |
| 302 | + | case port.MenuActionSaveImage: | |
| 303 | + | labels = append(labels, "Save Image", "Save image as...") | |
| 304 | + | case port.MenuActionInspectElement: | |
| 305 | + | labels = append(labels, "Inspect Element", "Inspect") | |
| 306 | + | case port.MenuActionCopySelection: | |
| 307 | + | labels = append(labels, "Copy Selection", "Copy") | |
| 308 | + | } | |
| 309 | + | return labels | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext { | |
| 313 | + | menuContext := port.MenuContext{} | |
| 314 | + | if wv != nil { | |
| 315 | + | menuContext.CanGoBack = wv.CanGoBack() | |
| 316 | + | menuContext.CanGoForward = wv.CanGoForward() | |
| 317 | + | menuContext.PageURI = wv.URI() | |
| 318 | + | } | |
| 319 | + | if params == nil { | |
| 320 | + | return menuContext | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | if pageURI := params.GetPageURL(); pageURI != "" { | |
| 324 | + | menuContext.PageURI = pageURI | |
| 325 | + | } | |
| 326 | + | menuContext.LinkURI = params.GetLinkURL() | |
| 327 | + | if params.HasImageContents() { | |
| 328 | + | menuContext.ImageURI = params.GetSourceURL() | |
| 329 | + | } | |
| 330 | + | menuContext.SelectionText = params.GetSelectionText() | |
| 331 | + | menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != "" | |
| 332 | + | menuContext.IsEditable = params.IsEditable() | |
| 333 | + | menuContext.X = int(params.GetXcoord()) | |
| 334 | + | menuContext.Y = int(params.GetYcoord()) | |
| 335 | + | return menuContext | |
| 120 | 336 | } | |
| 121 | 337 | ||
| 122 | 338 | func (h *handlerSet) OnContextMenuCommand( | |
A internal/infrastructure/cef/context_menu_adapter_test.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type stubContextMenuParams struct { | |
| 14 | + | x, y int32 | |
| 15 | + | pageURL string | |
| 16 | + | linkURL string | |
| 17 | + | sourceURL string | |
| 18 | + | selection string | |
| 19 | + | editable bool | |
| 20 | + | imageContent bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (p stubContextMenuParams) GetXcoord() int32 { return p.x } | |
| 24 | + | func (p stubContextMenuParams) GetYcoord() int32 { return p.y } | |
| 25 | + | func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 } | |
| 26 | + | func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL } | |
| 27 | + | func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL } | |
| 28 | + | func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL } | |
| 29 | + | func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent } | |
| 30 | + | func (p stubContextMenuParams) GetTitleText() string { return "" } | |
| 31 | + | func (p stubContextMenuParams) GetPageURL() string { return p.pageURL } | |
| 32 | + | func (p stubContextMenuParams) GetFrameURL() string { return "" } | |
| 33 | + | func (p stubContextMenuParams) GetFrameCharset() string { return "" } | |
| 34 | + | func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 } | |
| 35 | + | func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags { | |
| 36 | + | return 0 | |
| 37 | + | } | |
| 38 | + | func (p stubContextMenuParams) GetSelectionText() string { return p.selection } | |
| 39 | + | func (p stubContextMenuParams) GetMisspelledWord() string { return "" } | |
| 40 | + | func (p stubContextMenuParams) GetDictionarySuggestions(purecef.StringList) int32 { return 0 } | |
| 41 | + | func (p stubContextMenuParams) IsEditable() bool { return p.editable } | |
| 42 | + | func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false } | |
| 43 | + | func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags { | |
| 44 | + | return 0 | |
| 45 | + | } | |
| 46 | + | func (p stubContextMenuParams) IsCustomMenu() bool { return false } | |
| 47 | + | ||
| 48 | + | func TestBuildMenuContextFromCEFParams(t *testing.T) { | |
| 49 | + | wv := &WebView{} | |
| 50 | + | wv.updateURI("https://example.com/page") | |
| 51 | + | wv.updateLoadState(false, true, false) | |
| 52 | + | ||
| 53 | + | ctx := buildMenuContext(wv, stubContextMenuParams{ | |
| 54 | + | x: 17, | |
| 55 | + | y: 42, | |
| 56 | + | pageURL: "https://example.com/page", | |
| 57 | + | linkURL: "https://example.com/link", | |
| 58 | + | sourceURL: "https://example.com/image.png", | |
| 59 | + | selection: "selected text", | |
| 60 | + | editable: true, | |
| 61 | + | imageContent: true, | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | require.Equal(t, "https://example.com/page", ctx.PageURI) | |
| 65 | + | require.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 66 | + | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | |
| 67 | + | require.Equal(t, "selected text", ctx.SelectionText) | |
| 68 | + | require.True(t, ctx.HasSelection) | |
| 69 | + | require.True(t, ctx.IsEditable) | |
| 70 | + | require.True(t, ctx.CanGoBack) | |
| 71 | + | require.False(t, ctx.CanGoForward) | |
| 72 | + | require.Equal(t, 17, ctx.X) | |
| 73 | + | require.Equal(t, 42, ctx.Y) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | type stubRunContextMenuCallback struct { | |
| 77 | + | contCalls int | |
| 78 | + | cancelCalls int | |
| 79 | + | commandID int32 | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type stubContextMenuExecutor struct { | |
| 83 | + | executeCalls int | |
| 84 | + | action port.MenuAction | |
| 85 | + | menuContext port.MenuContext | |
| 86 | + | err error | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (s *stubContextMenuExecutor) ExecuteMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 90 | + | s.executeCalls++ | |
| 91 | + | s.action = action | |
| 92 | + | s.menuContext = menuContext | |
| 93 | + | return s.err | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) { | |
| 97 | + | c.contCalls++ | |
| 98 | + | c.commandID = commandID | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (c *stubRunContextMenuCallback) Cancel() { | |
| 102 | + | c.cancelCalls++ | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 106 | + | callback := &stubRunContextMenuCallback{} | |
| 107 | + | ||
| 108 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{ | |
| 109 | + | port.MenuActionReload: 102, | |
| 110 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 111 | + | ||
| 112 | + | require.Zero(t, callback.contCalls) | |
| 113 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 117 | + | callback := &stubRunContextMenuCallback{} | |
| 118 | + | ||
| 119 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{ | |
| 120 | + | port.MenuActionInspectElement: 204, | |
| 121 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 122 | + | ||
| 123 | + | require.Equal(t, 1, callback.contCalls) | |
| 124 | + | require.Zero(t, callback.cancelCalls) | |
| 125 | + | require.Equal(t, int32(204), callback.commandID) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testing.T) { | |
| 129 | + | callback := &stubRunContextMenuCallback{} | |
| 130 | + | executor := &stubContextMenuExecutor{} | |
| 131 | + | menuContext := port.MenuContext{PageURI: "https://example.com"} | |
| 132 | + | ||
| 133 | + | dispatchContextMenuSelection( | |
| 134 | + | context.Background(), | |
| 135 | + | executor, | |
| 136 | + | callback, | |
| 137 | + | nil, | |
| 138 | + | map[port.MenuAction]int32{port.MenuActionReload: 102}, | |
| 139 | + | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | |
| 140 | + | menuContext, | |
| 141 | + | ) | |
| 142 | + | ||
| 143 | + | require.Equal(t, 1, executor.executeCalls) | |
| 144 | + | require.Equal(t, port.MenuActionReload, executor.action) | |
| 145 | + | require.Equal(t, menuContext, executor.menuContext) | |
| 146 | + | require.Zero(t, callback.contCalls) | |
| 147 | + | require.Zero(t, callback.commandID) | |
| 148 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | |
| 152 | + | callback := &stubRunContextMenuCallback{} | |
| 153 | + | executor := &stubContextMenuExecutor{} | |
| 154 | + | menuContext := port.MenuContext{ImageURI: "https://example.com/image.png"} | |
| 155 | + | ||
| 156 | + | dispatchContextMenuSelection( | |
| 157 | + | context.Background(), | |
| 158 | + | executor, | |
| 159 | + | callback, | |
| 160 | + | nil, | |
| 161 | + | map[port.MenuAction]int32{}, | |
| 162 | + | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 163 | + | menuContext, | |
| 164 | + | ) | |
| 165 | + | ||
| 166 | + | require.Equal(t, 1, executor.executeCalls) | |
| 167 | + | require.Equal(t, port.MenuActionCopyImage, executor.action) | |
| 168 | + | require.Equal(t, menuContext, executor.menuContext) | |
| 169 | + | require.Zero(t, callback.contCalls) | |
| 170 | + | require.Zero(t, callback.commandID) | |
| 171 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) { | |
| 175 | + | callback := &stubRunContextMenuCallback{} | |
| 176 | + | executor := &stubContextMenuExecutor{} | |
| 177 | + | var copiedLens []int | |
| 178 | + | menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true} | |
| 179 | + | ||
| 180 | + | dispatchContextMenuSelection( | |
| 181 | + | context.Background(), | |
| 182 | + | executor, | |
| 183 | + | callback, | |
| 184 | + | func(text string) { copiedLens = append(copiedLens, len(text)) }, | |
| 185 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |
| 186 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |
| 187 | + | menuContext, | |
| 188 | + | ) | |
| 189 | + | ||
| 190 | + | require.Equal(t, 1, executor.executeCalls) | |
| 191 | + | require.Equal(t, port.MenuActionCopySelection, executor.action) | |
| 192 | + | require.Equal(t, menuContext, executor.menuContext) | |
| 193 | + | require.Zero(t, callback.contCalls) | |
| 194 | + | require.Zero(t, callback.commandID) | |
| 195 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 196 | + | require.Equal(t, []int{len("selected text")}, copiedLens) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) { | |
| 200 | + | callback := &stubRunContextMenuCallback{} | |
| 201 | + | executor := &stubContextMenuExecutor{} | |
| 202 | + | ||
| 203 | + | dispatchContextMenuSelection( | |
| 204 | + | context.Background(), | |
| 205 | + | executor, | |
| 206 | + | callback, | |
| 207 | + | nil, | |
| 208 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |
| 209 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |
| 210 | + | port.MenuContext{HasSelection: true}, | |
| 211 | + | ) | |
| 212 | + | ||
| 213 | + | require.Equal(t, 1, callback.contCalls) | |
| 214 | + | require.Zero(t, callback.cancelCalls) | |
| 215 | + | require.Equal(t, int32(333), callback.commandID) | |
| 216 | + | require.Zero(t, executor.executeCalls) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | |
| 220 | + | x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2) | |
| 221 | + | ||
| 222 | + | require.Equal(t, int32(160), x) | |
| 223 | + | require.Equal(t, int32(90), y) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) { | |
| 227 | + | x, y := contextMenuRawPosition(nil) | |
| 228 | + | ||
| 229 | + | require.Zero(t, x) | |
| 230 | + | require.Zero(t, y) | |
| 231 | + | } | |
A internal/infrastructure/cef/context_menu_run_context_menu_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type stubContextMenuBuilder struct{ items []port.MenuItem } | |
| 12 | + | ||
| 13 | + | func (b stubContextMenuBuilder) Build(context.Context, port.MenuContext) []port.MenuItem { | |
| 14 | + | return b.items | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestRunContextMenuReturnsZeroWhenAnchorMissing(t *testing.T) { | |
| 18 | + | h := &handlerSet{wv: &WebView{engine: &Engine{ctxMenuBuilder: stubContextMenuBuilder{items: []port.MenuItem{{Action: port.MenuActionCopySelection, Label: "Copy"}}}}}} | |
| 19 | + | callback := &stubRunContextMenuCallback{} | |
| 20 | + | ||
| 21 | + | result := h.RunContextMenu(nil, nil, stubContextMenuParams{}, stubMenuModel{}, callback) | |
| 22 | + | ||
| 23 | + | require.Equal(t, int32(0), result) | |
| 24 | + | require.Zero(t, callback.cancelCalls) | |
| 25 | + | } | |
A internal/infrastructure/cef/download_handler.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // maxFinishedEntries caps the finished-ID set. When the cap is reached the set | |
| 17 | + | // is cleared, accepting a tiny risk of a duplicate terminal event for very old | |
| 18 | + | // downloads. In practice download IDs are monotonically increasing uint32s and | |
| 19 | + | // no user session will approach this limit. | |
| 20 | + | const maxFinishedEntries = 1024 | |
| 21 | + | ||
| 22 | + | type downloadHandler struct { | |
| 23 | + | runtime *downloadruntime.Runtime | |
| 24 | + | mu sync.Mutex | |
| 25 | + | active map[uint32]cefDownloadState | |
| 26 | + | finished map[uint32]struct{} // IDs that already emitted a terminal event | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type cefDownloadState struct { | |
| 30 | + | filename string | |
| 31 | + | destination string | |
| 32 | + | lastProgressPercent int32 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type cefDownloadInterruptError struct { | |
| 36 | + | filename string | |
| 37 | + | reason purecef.DownloadInterruptReason | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (e cefDownloadInterruptError) Error() string { | |
| 41 | + | return fmt.Sprintf( | |
| 42 | + | "download interrupted: %s (code=%d, reason=%s)", | |
| 43 | + | e.filename, | |
| 44 | + | e.reason, | |
| 45 | + | downloadInterruptReasonLabel(e.reason), | |
| 46 | + | ) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (e cefDownloadInterruptError) DownloadErrorCode() int { | |
| 50 | + | return int(e.reason) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (e cefDownloadInterruptError) DownloadErrorReason() string { | |
| 54 | + | return downloadInterruptReasonLabel(e.reason) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type cefDownloadResponseAdapter struct { | |
| 58 | + | item purecef.DownloadItem | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{ | |
| 62 | + | purecef.DownloadInterruptReasonNone: "none", | |
| 63 | + | purecef.DownloadInterruptReasonFileFailed: "file_failed", | |
| 64 | + | purecef.DownloadInterruptReasonFileAccessDenied: "file_access_denied", | |
| 65 | + | purecef.DownloadInterruptReasonFileNoSpace: "file_no_space", | |
| 66 | + | purecef.DownloadInterruptReasonFileNameTooLong: "file_name_too_long", | |
| 67 | + | purecef.DownloadInterruptReasonFileTooLarge: "file_too_large", | |
| 68 | + | purecef.DownloadInterruptReasonFileVirusInfected: "file_virus_infected", | |
| 69 | + | purecef.DownloadInterruptReasonFileTransientError: "file_transient_error", | |
| 70 | + | purecef.DownloadInterruptReasonFileBlocked: "file_blocked", | |
| 71 | + | purecef.DownloadInterruptReasonFileSecurityCheckFailed: "file_security_check_failed", | |
| 72 | + | purecef.DownloadInterruptReasonFileTooShort: "file_too_short", | |
| 73 | + | purecef.DownloadInterruptReasonFileHashMismatch: "file_hash_mismatch", | |
| 74 | + | purecef.DownloadInterruptReasonFileSameAsSource: "file_same_as_source", | |
| 75 | + | purecef.DownloadInterruptReasonNetworkFailed: "network_failed", | |
| 76 | + | purecef.DownloadInterruptReasonNetworkTimeout: "network_timeout", | |
| 77 | + | purecef.DownloadInterruptReasonNetworkDisconnected: "network_disconnected", | |
| 78 | + | purecef.DownloadInterruptReasonNetworkServerDown: "network_server_down", | |
| 79 | + | purecef.DownloadInterruptReasonNetworkInvalidRequest: "network_invalid_request", | |
| 80 | + | purecef.DownloadInterruptReasonServerFailed: "server_failed", | |
| 81 | + | purecef.DownloadInterruptReasonServerNoRange: "server_no_range", | |
| 82 | + | purecef.DownloadInterruptReasonServerBadContent: "server_bad_content", | |
| 83 | + | purecef.DownloadInterruptReasonServerUnauthorized: "server_unauthorized", | |
| 84 | + | purecef.DownloadInterruptReasonServerCertProblem: "server_cert_problem", | |
| 85 | + | purecef.DownloadInterruptReasonServerForbidden: "server_forbidden", | |
| 86 | + | purecef.DownloadInterruptReasonServerUnreachable: "server_unreachable", | |
| 87 | + | purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch", | |
| 88 | + | purecef.DownloadInterruptReasonServerCrossOriginRedirect: "server_cross_origin_redirect", | |
| 89 | + | purecef.DownloadInterruptReasonUserCanceled: "user_canceled", | |
| 90 | + | purecef.DownloadInterruptReasonUserShutdown: "user_shutdown", | |
| 91 | + | purecef.DownloadInterruptReasonCrash: "crash", | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string { | |
| 95 | + | if label, ok := downloadInterruptReasonLabels[reason]; ok { | |
| 96 | + | return label | |
| 97 | + | } | |
| 98 | + | return "unknown" | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func newDownloadHandler( | |
| 102 | + | downloadPath string, | |
| 103 | + | eventHandler port.DownloadEventHandler, | |
| 104 | + | preparer port.DownloadPreparer, | |
| 105 | + | ) *downloadHandler { | |
| 106 | + | return &downloadHandler{ | |
| 107 | + | runtime: downloadruntime.New(downloadPath, eventHandler, preparer), | |
| 108 | + | active: make(map[uint32]cefDownloadState), | |
| 109 | + | finished: make(map[uint32]struct{}), | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool { | |
| 114 | + | return true | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (h *downloadHandler) onBeforeDownload( | |
| 118 | + | ctx context.Context, | |
| 119 | + | _ purecef.Browser, | |
| 120 | + | downloadItem purecef.DownloadItem, | |
| 121 | + | suggestedName string, | |
| 122 | + | callback purecef.BeforeDownloadCallback, | |
| 123 | + | ) bool { | |
| 124 | + | if downloadItem == nil || callback == nil { | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | log := logging.FromContext(ctx) | |
| 129 | + | output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem}) | |
| 130 | + | if err != nil { | |
| 131 | + | log.Error().Err(err).Msg("cef: failed to prepare download destination") | |
| 132 | + | callback.Cont("", 1) | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | h.mu.Lock() | |
| 137 | + | h.active[downloadItem.GetID()] = cefDownloadState{ | |
| 138 | + | filename: output.Filename, | |
| 139 | + | destination: output.DestinationPath, | |
| 140 | + | lastProgressPercent: -1, | |
| 141 | + | } | |
| 142 | + | h.mu.Unlock() | |
| 143 | + | ||
| 144 | + | callback.Cont(output.DestinationPath, 0) | |
| 145 | + | h.runtime.EmitStarted(ctx, output) | |
| 146 | + | ||
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (h *downloadHandler) onDownloadUpdated( | |
| 151 | + | ctx context.Context, | |
| 152 | + | downloadItem purecef.DownloadItem, | |
| 153 | + | callback purecef.DownloadItemCallback, | |
| 154 | + | ) { | |
| 155 | + | _ = callback | |
| 156 | + | if downloadItem == nil { | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | id := downloadItem.GetID() | |
| 161 | + | ||
| 162 | + | // Fast path: skip already-finished downloads to avoid re-populating active. | |
| 163 | + | h.mu.Lock() | |
| 164 | + | _, done := h.finished[id] | |
| 165 | + | h.mu.Unlock() | |
| 166 | + | if done { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | state := h.currentState(id, downloadItem) | |
| 171 | + | h.emitProgressIfNeeded(ctx, id, downloadItem, state) | |
| 172 | + | ||
| 173 | + | switch { | |
| 174 | + | case downloadItem.IsComplete(): | |
| 175 | + | if !h.markFinished(id) { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | h.runtime.EmitFinished(ctx, state.filename, state.destination) | |
| 179 | + | case downloadItem.IsCanceled() || downloadItem.IsInterrupted(): | |
| 180 | + | if !h.markFinished(id) { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | var err error | |
| 184 | + | if downloadItem.IsInterrupted() { | |
| 185 | + | err = cefDownloadInterruptError{ | |
| 186 | + | filename: state.filename, | |
| 187 | + | reason: downloadItem.GetInterruptReason(), | |
| 188 | + | } | |
| 189 | + | } else { | |
| 190 | + | err = fmt.Errorf("download canceled: %s", state.filename) | |
| 191 | + | } | |
| 192 | + | h.runtime.EmitFailed(ctx, state.filename, state.destination, err) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState { | |
| 197 | + | h.mu.Lock() | |
| 198 | + | defer h.mu.Unlock() | |
| 199 | + | ||
| 200 | + | state, exists := h.active[id] | |
| 201 | + | if !exists { | |
| 202 | + | state = cefDownloadState{} | |
| 203 | + | } | |
| 204 | + | updated := false | |
| 205 | + | if state.destination == "" { | |
| 206 | + | state.destination = item.GetFullPath() | |
| 207 | + | updated = true | |
| 208 | + | } | |
| 209 | + | if state.filename == "" { | |
| 210 | + | state.filename = downloadFilenameFromItem(item, state.destination) | |
| 211 | + | updated = true | |
| 212 | + | } | |
| 213 | + | if exists && updated { | |
| 214 | + | h.active[id] = state | |
| 215 | + | } | |
| 216 | + | return state | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (h *downloadHandler) emitProgressIfNeeded( | |
| 220 | + | ctx context.Context, | |
| 221 | + | id uint32, | |
| 222 | + | item purecef.DownloadItem, | |
| 223 | + | state cefDownloadState, | |
| 224 | + | ) { | |
| 225 | + | percent, ok := downloadProgressPercentFromItem(item) | |
| 226 | + | if !ok { | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | h.mu.Lock() | |
| 231 | + | current, exists := h.active[id] | |
| 232 | + | if !exists { | |
| 233 | + | current = state | |
| 234 | + | } | |
| 235 | + | if current.lastProgressPercent == percent { | |
| 236 | + | h.mu.Unlock() | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | current.lastProgressPercent = percent | |
| 240 | + | h.active[id] = current | |
| 241 | + | h.mu.Unlock() | |
| 242 | + | ||
| 243 | + | h.runtime.EmitProgress( | |
| 244 | + | ctx, | |
| 245 | + | current.filename, | |
| 246 | + | current.destination, | |
| 247 | + | float64(percent)/100, | |
| 248 | + | item.GetReceivedBytes(), | |
| 249 | + | item.GetTotalBytes(), | |
| 250 | + | ) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) { | |
| 254 | + | if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() { | |
| 255 | + | return 0, false | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | percent := item.GetPercentComplete() | |
| 259 | + | if percent < 0 { | |
| 260 | + | total := item.GetTotalBytes() | |
| 261 | + | received := item.GetReceivedBytes() | |
| 262 | + | if total <= 0 || received <= 0 { | |
| 263 | + | return 0, false | |
| 264 | + | } | |
| 265 | + | percent = int32((received * 100) / total) | |
| 266 | + | } | |
| 267 | + | if percent <= 0 || percent >= 100 { | |
| 268 | + | return 0, false | |
| 269 | + | } | |
| 270 | + | return percent, true | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (h *downloadHandler) markFinished(id uint32) bool { | |
| 274 | + | h.mu.Lock() | |
| 275 | + | defer h.mu.Unlock() | |
| 276 | + | ||
| 277 | + | delete(h.active, id) | |
| 278 | + | if _, seen := h.finished[id]; seen { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | if len(h.finished) >= maxFinishedEntries { | |
| 282 | + | clear(h.finished) | |
| 283 | + | } | |
| 284 | + | h.finished[id] = struct{}{} | |
| 285 | + | return true | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string { | |
| 289 | + | if destination != "" { | |
| 290 | + | return downloadutil.ExtractFilenameFromDestination(destination) | |
| 291 | + | } | |
| 292 | + | if item == nil { | |
| 293 | + | return downloadutil.DefaultFilename | |
| 294 | + | } | |
| 295 | + | if suggested := item.GetSuggestedFileName(); suggested != "" { | |
| 296 | + | return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType()) | |
| 297 | + | } | |
| 298 | + | if uri := item.GetURL(); uri != "" { | |
| 299 | + | return downloadutil.ExtractFilenameFromURI(uri) | |
| 300 | + | } | |
| 301 | + | return downloadutil.DefaultFilename | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (a *cefDownloadResponseAdapter) GetMimeType() string { | |
| 305 | + | if a == nil || a.item == nil { | |
| 306 | + | return "" | |
| 307 | + | } | |
| 308 | + | return a.item.GetMimeType() | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string { | |
| 312 | + | if a == nil || a.item == nil { | |
| 313 | + | return "" | |
| 314 | + | } | |
| 315 | + | return a.item.GetSuggestedFileName() | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (a *cefDownloadResponseAdapter) GetUri() string { | |
| 319 | + | if a == nil || a.item == nil { | |
| 320 | + | return "" | |
| 321 | + | } | |
| 322 | + | return a.item.GetURL() | |
| 323 | + | } | |
A internal/infrastructure/cef/download_handler_test.go +209 -0
| @@ -0,0 +1,209 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type stubDownloadItem struct { | |
| 17 | + | id uint32 | |
| 18 | + | url string | |
| 19 | + | suggested string | |
| 20 | + | mimeType string | |
| 21 | + | fullPath string | |
| 22 | + | complete bool | |
| 23 | + | canceled bool | |
| 24 | + | interrupted bool | |
| 25 | + | reason purecef.DownloadInterruptReason | |
| 26 | + | percentComplete int32 | |
| 27 | + | totalBytes int64 | |
| 28 | + | receivedBytes int64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (s stubDownloadItem) IsValid() bool { return true } | |
| 32 | + | func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted } | |
| 33 | + | func (s stubDownloadItem) IsComplete() bool { return s.complete } | |
| 34 | + | func (s stubDownloadItem) IsCanceled() bool { return s.canceled } | |
| 35 | + | func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted } | |
| 36 | + | func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason { | |
| 37 | + | return s.reason | |
| 38 | + | } | |
| 39 | + | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | |
| 40 | + | func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete } | |
| 41 | + | func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes } | |
| 42 | + | func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes } | |
| 43 | + | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | |
| 44 | + | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | |
| 45 | + | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | |
| 46 | + | func (s stubDownloadItem) GetID() uint32 { return s.id } | |
| 47 | + | func (s stubDownloadItem) GetURL() string { return s.url } | |
| 48 | + | func (s stubDownloadItem) GetOriginalURL() string { return s.url } | |
| 49 | + | func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested } | |
| 50 | + | func (s stubDownloadItem) GetContentDisposition() string { return "" } | |
| 51 | + | func (s stubDownloadItem) GetMimeType() string { return s.mimeType } | |
| 52 | + | func (s stubDownloadItem) IsPaused() bool { return false } | |
| 53 | + | ||
| 54 | + | type stubBeforeDownloadCallback struct { | |
| 55 | + | path string | |
| 56 | + | showDialog int32 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) { | |
| 60 | + | s.path = downloadPath | |
| 61 | + | s.showDialog = showDialog | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type captureDownloadEvents struct { | |
| 65 | + | events []port.DownloadEvent | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |
| 69 | + | c.events = append(c.events, event) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |
| 73 | + | ctx := context.Background() | |
| 74 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 75 | + | events := &captureDownloadEvents{} | |
| 76 | + | downloadDir := t.TempDir() | |
| 77 | + | handler := newDownloadHandler(downloadDir, events, preparer) | |
| 78 | + | callback := &stubBeforeDownloadCallback{} | |
| 79 | + | ||
| 80 | + | item := stubDownloadItem{ | |
| 81 | + | id: 7, | |
| 82 | + | url: "https://example.com/image.iso", | |
| 83 | + | suggested: "image.iso", | |
| 84 | + | mimeType: "application/x-iso9660-image", | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback) | |
| 88 | + | ||
| 89 | + | require.True(t, ok) | |
| 90 | + | require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path) | |
| 91 | + | require.Len(t, events.events, 1) | |
| 92 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 93 | + | ||
| 94 | + | item.complete = true | |
| 95 | + | item.fullPath = callback.path | |
| 96 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 97 | + | // Repeated completion updates should not emit duplicate finished events. | |
| 98 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 99 | + | ||
| 100 | + | require.Len(t, events.events, 2) | |
| 101 | + | require.Equal(t, port.DownloadEventFinished, events.events[1].Type) | |
| 102 | + | require.Equal(t, "image.iso", events.events[1].Filename) | |
| 103 | + | ||
| 104 | + | // Active map should be cleaned up after terminal event. | |
| 105 | + | handler.mu.Lock() | |
| 106 | + | _, inActive := handler.active[7] | |
| 107 | + | _, inFinished := handler.finished[7] | |
| 108 | + | handler.mu.Unlock() | |
| 109 | + | require.False(t, inActive, "completed download should be removed from active map") | |
| 110 | + | require.True(t, inFinished, "completed download should be in finished set") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestCanDownload_AllowsAllMethods(t *testing.T) { | |
| 114 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 115 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 116 | + | ||
| 117 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET")) | |
| 118 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST")) | |
| 119 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT")) | |
| 120 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "")) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |
| 124 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 125 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 126 | + | ||
| 127 | + | // Simulate a download that was tracked through onBeforeDownload. | |
| 128 | + | handler.mu.Lock() | |
| 129 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |
| 130 | + | handler.mu.Unlock() | |
| 131 | + | ||
| 132 | + | require.True(t, handler.markFinished(42), "first terminal event should succeed") | |
| 133 | + | ||
| 134 | + | // Simulate a concurrent update re-populating active before a duplicate terminal update. | |
| 135 | + | handler.mu.Lock() | |
| 136 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |
| 137 | + | handler.mu.Unlock() | |
| 138 | + | ||
| 139 | + | require.False(t, handler.markFinished(42), "second terminal event should be suppressed") | |
| 140 | + | ||
| 141 | + | // Active map should not contain the entry. | |
| 142 | + | handler.mu.Lock() | |
| 143 | + | _, inActive := handler.active[42] | |
| 144 | + | handler.mu.Unlock() | |
| 145 | + | require.False(t, inActive) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |
| 149 | + | ctx := context.Background() | |
| 150 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 151 | + | events := &captureDownloadEvents{} | |
| 152 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 153 | + | callback := &stubBeforeDownloadCallback{} | |
| 154 | + | ||
| 155 | + | item := stubDownloadItem{ | |
| 156 | + | id: 11, | |
| 157 | + | url: "https://example.com/archive.zip", | |
| 158 | + | suggested: "archive.zip", | |
| 159 | + | mimeType: "application/zip", | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |
| 163 | + | ||
| 164 | + | item.fullPath = callback.path | |
| 165 | + | item.percentComplete = 12 | |
| 166 | + | item.receivedBytes = 12 | |
| 167 | + | item.totalBytes = 100 | |
| 168 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 169 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 170 | + | ||
| 171 | + | item.percentComplete = 13 | |
| 172 | + | item.receivedBytes = 13 | |
| 173 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 174 | + | ||
| 175 | + | require.Len(t, events.events, 3) | |
| 176 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 177 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |
| 178 | + | require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001) | |
| 179 | + | require.Equal(t, port.DownloadEventProgress, events.events[2].Type) | |
| 180 | + | require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |
| 184 | + | ctx := context.Background() | |
| 185 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 186 | + | events := &captureDownloadEvents{} | |
| 187 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 188 | + | callback := &stubBeforeDownloadCallback{} | |
| 189 | + | ||
| 190 | + | item := stubDownloadItem{ | |
| 191 | + | id: 9, | |
| 192 | + | url: "https://example.com/ubuntu.iso", | |
| 193 | + | suggested: "ubuntu.iso", | |
| 194 | + | mimeType: "application/x-iso9660-image", | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |
| 198 | + | ||
| 199 | + | item.fullPath = callback.path | |
| 200 | + | item.interrupted = true | |
| 201 | + | item.reason = purecef.DownloadInterruptReasonServerBadContent | |
| 202 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 203 | + | ||
| 204 | + | require.Len(t, events.events, 2) | |
| 205 | + | require.Error(t, events.events[1].Error) | |
| 206 | + | expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent) | |
| 207 | + | require.ErrorContains(t, events.events[1].Error, expectedCode) | |
| 208 | + | require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content") | |
| 209 | + | } | |
M internal/infrastructure/cef/engine.go +165 -11
| @@ -2,18 +2,23 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/subtle" | |
| 6 | + | "fmt" | |
| 5 | 7 | "sync" | |
| 6 | 8 | "sync/atomic" | |
| 7 | 9 | "time" | |
| 10 | + | "unicode/utf8" | |
| 8 | 11 | ||
| 9 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | 19 | // Compile-time interface check. | |
| 16 | 20 | var _ port.Engine = (*Engine)(nil) | |
| 21 | + | var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | |
| 17 | 22 | ||
| 18 | 23 | // Engine implements port.Engine for the CEF browser backend. | |
| 19 | 24 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| @@ -27,15 +32,26 @@ type Engine struct { | |||
| 27 | 32 | schemeHandler *dumbSchemeHandler | |
| 28 | 33 | contentInj *contentInjector | |
| 29 | 34 | ||
| 30 | - | registerHandlers HandlerRegistrar | |
| 31 | - | registerAccentHandlers AccentHandlerRegistrar | |
| 32 | - | currentConfigPayload func() ([]byte, error) | |
| 33 | - | defaultConfigPayload func() ([]byte, error) | |
| 34 | - | mediaClassifier MediaClassifier | |
| 35 | + | registerHandlers HandlerRegistrar | |
| 36 | + | registerAccentHandlers AccentHandlerRegistrar | |
| 37 | + | currentConfigPayload func() ([]byte, error) | |
| 38 | + | defaultConfigPayload func() ([]byte, error) | |
| 39 | + | ctxMenuBuilder port.ContextMenuBuilder | |
| 40 | + | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 41 | + | clipboard port.Clipboard | |
| 42 | + | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 43 | + | onClipboardCopied func(textLen int) | |
| 44 | + | resolver port.ImageDataResolver | |
| 45 | + | mediaClassifier MediaClassifier | |
| 46 | + | downloadMu sync.RWMutex | |
| 47 | + | downloadHandler *downloadHandler | |
| 48 | + | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 49 | + | alreadyRunningAppRelaunchHandler func(string) | |
| 35 | 50 | ||
| 36 | 51 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 37 | - | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 38 | - | activeCount atomic.Int32 | |
| 52 | + | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 53 | + | browserWebViews sync.Map // map[int32]*WebView | |
| 54 | + | activeCount atomic.Int32 | |
| 39 | 55 | ||
| 40 | 56 | // shutdownNotify is signaled when the active webview count reaches 0 | |
| 41 | 57 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| @@ -110,16 +126,66 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) { | |||
| 110 | 126 | } | |
| 111 | 127 | } | |
| 112 | 128 | ||
| 129 | + | // SetAlreadyRunningAppRelaunchHandler configures the callback invoked when CEF | |
| 130 | + | // relaunches an already-running app instance. | |
| 131 | + | func (e *Engine) SetAlreadyRunningAppRelaunchHandler(handler func(string)) { | |
| 132 | + | e.alreadyRunningAppRelaunchMu.Lock() | |
| 133 | + | defer e.alreadyRunningAppRelaunchMu.Unlock() | |
| 134 | + | e.alreadyRunningAppRelaunchHandler = handler | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) { | |
| 138 | + | e.alreadyRunningAppRelaunchMu.RLock() | |
| 139 | + | defer e.alreadyRunningAppRelaunchMu.RUnlock() | |
| 140 | + | return e.alreadyRunningAppRelaunchHandler | |
| 141 | + | } | |
| 142 | + | ||
| 113 | 143 | // registerWebView adds a webview to the active tracking map. | |
| 114 | 144 | func (e *Engine) registerWebView(wv *WebView) { | |
| 115 | 145 | e.activeWebViews.Store(wv.id, wv) | |
| 116 | 146 | e.activeCount.Add(1) | |
| 117 | 147 | } | |
| 118 | 148 | ||
| 149 | + | func (e *Engine) lookupWebView(id port.WebViewID) *WebView { | |
| 150 | + | if e == nil { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | current, ok := e.activeWebViews.Load(id) | |
| 154 | + | if !ok { | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | wv, _ := current.(*WebView) | |
| 158 | + | return wv | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) { | |
| 162 | + | if e == nil || browser == nil || wv == nil { | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | browserID := browser.GetIdentifier() | |
| 166 | + | if browserID <= 0 { | |
| 167 | + | logging.FromContext(e.ctx).Debug().Int32("browser_id", browserID).Msg("cef: skipped browser/webview binding for invalid browser id") | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | e.browserWebViews.Store(browserID, wv) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) { | |
| 174 | + | if e == nil || browserID <= 0 { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | if current, ok := e.browserWebViews.Load(browserID); ok { | |
| 178 | + | if existing, ok := current.(*WebView); ok && existing == wv { | |
| 179 | + | e.browserWebViews.Delete(browserID) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 119 | 184 | // unregisterWebView removes a webview from the active tracking map. | |
| 120 | 185 | // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify. | |
| 121 | - | func (e *Engine) unregisterWebView(wv *WebView) { | |
| 186 | + | func (e *Engine) unregisterWebView(wv *WebView, browserID int32) { | |
| 122 | 187 | e.activeWebViews.Delete(wv.id) | |
| 188 | + | e.unbindBrowserWebView(browserID, wv) | |
| 123 | 189 | if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil { | |
| 124 | 190 | select { | |
| 125 | 191 | case e.shutdownNotify <- struct{}{}: | |
| @@ -215,6 +281,8 @@ func (e *Engine) closeActiveWebViews() { | |||
| 215 | 281 | // each handler is registered by message type with the router, which dispatches | |
| 216 | 282 | // incoming /api/message POSTs to the correct handler based on Message.Type. | |
| 217 | 283 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | |
| 284 | + | e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator | |
| 285 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 218 | 286 | if e.messageRouter == nil || e.registerHandlers == nil { | |
| 219 | 287 | return nil | |
| 220 | 288 | } | |
| @@ -228,9 +296,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 228 | 296 | return e.registerAccentHandlers(ctx, e.messageRouter, handler) | |
| 229 | 297 | } | |
| 230 | 298 | ||
| 231 | - | // ConfigureDownloads sets up download handling (Phase 1 stub). | |
| 232 | - | func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error { | |
| 233 | - | return ErrDownloadsUnsupported | |
| 299 | + | // ConfigureDownloads sets up download handling for all CEF webviews. | |
| 300 | + | func (e *Engine) ConfigureDownloads( | |
| 301 | + | _ context.Context, | |
| 302 | + | downloadPath string, | |
| 303 | + | eventHandler port.DownloadEventHandler, | |
| 304 | + | preparer port.DownloadPreparer, | |
| 305 | + | ) error { | |
| 306 | + | if preparer == nil { | |
| 307 | + | return fmt.Errorf("cef: download preparer is required") | |
| 308 | + | } | |
| 309 | + | e.downloadMu.Lock() | |
| 310 | + | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer) | |
| 311 | + | e.downloadMu.Unlock() | |
| 312 | + | return nil | |
| 234 | 313 | } | |
| 235 | 314 | ||
| 236 | 315 | // OnToolkitReady is called after the UI toolkit has initialized. | |
| @@ -255,6 +334,53 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||
| 255 | 334 | return nil | |
| 256 | 335 | } | |
| 257 | 336 | ||
| 337 | + | func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) { | |
| 338 | + | if e == nil || text == "" { | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | if e.clipboardTextOrchestrator == nil { | |
| 342 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 343 | + | Str("action", action). | |
| 344 | + | Msg("cef: clipboard orchestration skipped — orchestrator nil") | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), dto.ExplicitClipboardInput{ | |
| 348 | + | Text: text, | |
| 349 | + | Action: action, | |
| 350 | + | SourceEngine: dto.ClipboardSourceCEF, | |
| 351 | + | ViewID: uint64(viewID), | |
| 352 | + | }); err != nil { | |
| 353 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 354 | + | Err(err). | |
| 355 | + | Str("action", action). | |
| 356 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 357 | + | Msg("cef: clipboard explicit copy handling failed") | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text string) { | |
| 362 | + | if e == nil || e.clipboardTextOrchestrator == nil { | |
| 363 | + | return | |
| 364 | + | } | |
| 365 | + | if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), dto.SelectionClipboardInput{ | |
| 366 | + | Text: text, | |
| 367 | + | SourceEngine: dto.ClipboardSourceCEF, | |
| 368 | + | ViewID: uint64(viewID), | |
| 369 | + | }); err != nil { | |
| 370 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 371 | + | Err(err). | |
| 372 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 373 | + | Msg("cef: clipboard selection handling failed") | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func (e *Engine) notifyClipboardCopied(text string) { | |
| 378 | + | if e == nil || e.onClipboardCopied == nil || text == "" { | |
| 379 | + | return | |
| 380 | + | } | |
| 381 | + | e.onClipboardCopied(utf8.RuneCountInString(text)) | |
| 382 | + | } | |
| 383 | + | ||
| 258 | 384 | // SetHandlerContext sets the base context for message handler dispatch. | |
| 259 | 385 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 260 | 386 | e.ctx = ctx | |
| @@ -338,6 +464,34 @@ func (e *Engine) currentContext() context.Context { | |||
| 338 | 464 | return e.ctx | |
| 339 | 465 | } | |
| 340 | 466 | ||
| 467 | + | func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce string) bool { | |
| 468 | + | if e == nil || browser == nil || bridgeNonce == "" { | |
| 469 | + | return false | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | wv := e.webViewForBrowser(browser) | |
| 473 | + | if wv == nil { | |
| 474 | + | return false | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | wv.mu.RLock() | |
| 478 | + | wvBridgeNonce := wv.bridgeNonce | |
| 479 | + | wv.mu.RUnlock() | |
| 480 | + | if wvBridgeNonce == "" { | |
| 481 | + | return false | |
| 482 | + | } | |
| 483 | + | return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func (e *Engine) currentDownloadHandler() *downloadHandler { | |
| 487 | + | if e == nil { | |
| 488 | + | return nil | |
| 489 | + | } | |
| 490 | + | e.downloadMu.RLock() | |
| 491 | + | defer e.downloadMu.RUnlock() | |
| 492 | + | return e.downloadHandler | |
| 493 | + | } | |
| 494 | + | ||
| 341 | 495 | func (e *Engine) logTranscoderStartupState() { | |
| 342 | 496 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 343 | 497 | return | |
M internal/infrastructure/cef/engine_contract_test.go +17 -2
| @@ -5,13 +5,28 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | - | func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) { | |
| 12 | + | func TestEngineConfigureDownloads_StoresHandler(t *testing.T) { | |
| 12 | 13 | eng := &Engine{} | |
| 14 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 15 | + | ||
| 16 | + | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer) | |
| 17 | + | ||
| 18 | + | require.NoError(t, err) | |
| 19 | + | require.NotNil(t, eng.currentDownloadHandler()) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestEngineConfigureDownloads_NilPreparer(t *testing.T) { | |
| 23 | + | eng := &Engine{} | |
| 24 | + | ||
| 13 | 25 | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil) | |
| 14 | - | require.ErrorIs(t, err, ErrDownloadsUnsupported) | |
| 26 | + | ||
| 27 | + | require.Error(t, err) | |
| 28 | + | require.Contains(t, err.Error(), "preparer is required") | |
| 29 | + | require.Nil(t, eng.currentDownloadHandler()) | |
| 15 | 30 | } | |
| 16 | 31 | ||
| 17 | 32 | func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) { | |
M internal/infrastructure/cef/engine_init.go +211 -153
| @@ -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" | |
| @@ -22,11 +23,19 @@ import ( | |||
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | 25 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 26 | + | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |
| 27 | + | ||
| 28 | + | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 29 | + | type RuntimePaths struct { | |
| 30 | + | StateRoot string | |
| 31 | + | LogFile string | |
| 32 | + | } | |
| 25 | 33 | ||
| 26 | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 27 | 35 | func NewEngine( | |
| 28 | 36 | ctx context.Context, | |
| 29 | 37 | opts port.EngineOptions, | |
| 38 | + | paths RuntimePaths, | |
| 30 | 39 | cfg RuntimeConfig, | |
| 31 | 40 | transcodingCfg TranscodingRuntimeConfig, | |
| 32 | 41 | audioFactory port.AudioOutputFactory, | |
| @@ -34,11 +43,11 @@ func NewEngine( | |||
| 34 | 43 | ) (*Engine, error) { | |
| 35 | 44 | logger := logging.FromContext(ctx) | |
| 36 | 45 | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 37 | - | stateRoot := resolvedStateRoot(opts) | |
| 46 | + | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 38 | 47 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 39 | 48 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| 40 | 49 | ||
| 41 | - | settings, err := prepareCEFSettings(opts, cfg, logger) | |
| 50 | + | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | |
| 42 | 51 | if err != nil { | |
| 43 | 52 | return nil, err | |
| 44 | 53 | } | |
| @@ -54,6 +63,10 @@ func NewEngine( | |||
| 54 | 63 | registerAccentHandlers: deps.RegisterAccentHandlers, | |
| 55 | 64 | currentConfigPayload: deps.CurrentConfigPayload, | |
| 56 | 65 | defaultConfigPayload: deps.DefaultConfigPayload, | |
| 66 | + | ctxMenuBuilder: deps.ContextMenuBuilder, | |
| 67 | + | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | |
| 68 | + | clipboard: deps.Clipboard, | |
| 69 | + | resolver: deps.ImageDataResolver, | |
| 57 | 70 | mediaClassifier: deps.MediaClassifier, | |
| 58 | 71 | } | |
| 59 | 72 | ||
| @@ -62,7 +75,6 @@ func NewEngine( | |||
| 62 | 75 | Bool("external_begin_frame", externalBeginFrameEnabled()). | |
| 63 | 76 | Bool("trace_handlers", cfg.TraceHandlers). | |
| 64 | 77 | Bool("enable_audio_handler", cfg.EnableAudioHandler). | |
| 65 | - | Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler). | |
| 66 | 78 | Msg("cef: configured engine") | |
| 67 | 79 | ||
| 68 | 80 | if err := initializeCEF(eng, settings, logger); err != nil { | |
| @@ -85,19 +97,26 @@ func NewEngine( | |||
| 85 | 97 | ) | |
| 86 | 98 | } | |
| 87 | 99 | ||
| 88 | - | func resolvedStateRoot(opts port.EngineOptions) string { | |
| 89 | - | switch { | |
| 90 | - | case opts.DataDir != "": | |
| 91 | - | return opts.DataDir | |
| 92 | - | case opts.CacheDir != "": | |
| 100 | + | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | |
| 101 | + | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 102 | + | return root | |
| 103 | + | } | |
| 104 | + | if opts.CacheDir != "" { | |
| 93 | 105 | return opts.CacheDir | |
| 94 | - | default: | |
| 95 | - | return defaultCEFUserDataDir() | |
| 96 | 106 | } | |
| 107 | + | if opts.DataDir != "" { | |
| 108 | + | return opts.DataDir | |
| 109 | + | } | |
| 110 | + | return defaultStateRoot | |
| 97 | 111 | } | |
| 98 | 112 | ||
| 99 | 113 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 100 | - | func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) { | |
| 114 | + | func prepareCEFSettings( | |
| 115 | + | opts port.EngineOptions, | |
| 116 | + | paths RuntimePaths, | |
| 117 | + | cfg RuntimeConfig, | |
| 118 | + | logger *zerolog.Logger, | |
| 119 | + | ) (purecef.Settings, error) { | |
| 101 | 120 | switch opts.CookiePolicy { | |
| 102 | 121 | case "", port.CookiePolicyAlways: | |
| 103 | 122 | case port.CookiePolicyNoThirdParty: | |
| @@ -113,49 +132,28 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||
| 113 | 132 | settings := purecef.DefaultSettings() | |
| 114 | 133 | settings.MultiThreadedMessageLoop = true | |
| 115 | 134 | settings.ExternalMessagePump = false | |
| 116 | - | settings.RootCachePath = resolvedStateRoot(opts) | |
| 135 | + | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | |
| 117 | 136 | if cfg.CEFDir != "" { | |
| 118 | 137 | settings.CEFDir = cfg.CEFDir | |
| 119 | 138 | } | |
| 120 | - | if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil { | |
| 139 | + | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | |
| 121 | 140 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 122 | 141 | } else { | |
| 123 | 142 | settings.LogFile = runtimeLogFile | |
| 124 | 143 | } | |
| 125 | - | if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil { | |
| 126 | - | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | |
| 127 | - | } else if bootstrapLogFile != "" { | |
| 128 | - | settings.InitTraceFile = bootstrapLogFile | |
| 129 | - | logger.Info(). | |
| 130 | - | Str("bootstrap_log_file", bootstrapLogFile). | |
| 131 | - | Str("env_var", puregoCEFInitTraceEnvVar). | |
| 132 | - | Msg("cef: init bootstrap diagnostics enabled") | |
| 144 | + | if puregoCEFInitTraceEnabled() { | |
| 145 | + | warn := logger.Warn().Str("env_var", puregoCEFInitTraceEnvVar) | |
| 146 | + | if bootstrapLogFile := resolveCEFInitTraceFile(paths.LogFile, cfg.LogFile); bootstrapLogFile != "" { | |
| 147 | + | warn = warn.Str("bootstrap_log_file", bootstrapLogFile) | |
| 148 | + | } | |
| 149 | + | warn.Msg("cef: init bootstrap diagnostics requested, but current purego-cef no longer consumes this trace file") | |
| 133 | 150 | } | |
| 134 | 151 | if cfg.LogSeverity != 0 { | |
| 135 | 152 | settings.LogSeverity = cfg.LogSeverity | |
| 136 | 153 | } | |
| 137 | - | if helperPath := findHelperBinary(); helperPath != "" { | |
| 138 | - | settings.BrowserSubprocessPath = helperPath | |
| 139 | - | logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper") | |
| 140 | - | } else { | |
| 141 | - | logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary") | |
| 142 | - | } | |
| 143 | 154 | return settings, nil | |
| 144 | 155 | } | |
| 145 | 156 | ||
| 146 | - | func defaultCEFUserDataDir() string { | |
| 147 | - | // Use Dumber's XDG helpers to get the config directory | |
| 148 | - | dirs, err := config.GetXDGDirs() | |
| 149 | - | if err != nil { | |
| 150 | - | // Fallback to HOME-based path if XDG resolution fails | |
| 151 | - | return filepath.Clean(filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data")) | |
| 152 | - | } | |
| 153 | - | if os.Getenv("ENV") == "dev" { | |
| 154 | - | return filepath.Join(dirs.ConfigHome, "cef_user_data") | |
| 155 | - | } | |
| 156 | - | return filepath.Join(filepath.Dir(dirs.ConfigHome), "cef_user_data") | |
| 157 | - | } | |
| 158 | - | ||
| 159 | 157 | // initializeCEF calls cef_initialize with the App to register custom schemes | |
| 160 | 158 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | |
| 161 | 159 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | |
| @@ -170,7 +168,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||
| 170 | 168 | ||
| 171 | 169 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 172 | 170 | func wireEngine( | |
| 173 | - | ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | |
| 171 | + | ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | |
| 174 | 172 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 175 | 173 | mediaClassifier MediaClassifier, | |
| 176 | 174 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| @@ -181,52 +179,80 @@ func wireEngine( | |||
| 181 | 179 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 182 | 180 | } | |
| 183 | 181 | ||
| 184 | - | scale := detectHiDPIScale(logger) | |
| 182 | + | mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger) | |
| 183 | + | eng.gl = gl | |
| 184 | + | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 185 | + | scale: detectHiDPIScale(logger), | |
| 186 | + | windowlessFrameRate: windowlessFrameRate, | |
| 187 | + | transcoder: mediaTranscoder, | |
| 188 | + | mediaClassifier: mediaClassifier, | |
| 189 | + | audioOutputFactory: audioFactory, | |
| 190 | + | }) | |
| 191 | + | eng.pool = newWebViewPool(eng.factory) | |
| 192 | + | eng.contentInj = newContentInjector(eng, nil) | |
| 193 | + | eng.transcoderState = transcoderState | |
| 185 | 194 | ||
| 186 | - | // Initialize the GPU transcoder when enabled in the loaded app config. | |
| 187 | - | var mediaTranscoder port.MediaTranscoder | |
| 188 | - | transcoderState := buildTranscoderStartupState(transcodingCfg) | |
| 189 | - | if transcodingCfg.Enabled { | |
| 190 | - | transcoderState.ProbeAttempted = true | |
| 191 | - | tc := transcoderpkg.New(config.TranscodingConfig{ | |
| 192 | - | Enabled: transcodingCfg.Enabled, | |
| 193 | - | HWAccel: transcodingCfg.HWAccel, | |
| 194 | - | MaxConcurrent: transcodingCfg.MaxConcurrent, | |
| 195 | - | Quality: transcodingCfg.Quality, | |
| 196 | - | }, logger) | |
| 197 | - | caps := tc.Capabilities() | |
| 198 | - | transcoderState.API = caps.API | |
| 199 | - | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |
| 200 | - | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |
| 201 | - | if tc.Available() { | |
| 202 | - | mediaTranscoder = tc | |
| 203 | - | transcoderState.Status = "available" | |
| 204 | - | logger.Info(). | |
| 205 | - | Str("api", tc.Capabilities().API). | |
| 206 | - | Strs("encoders", tc.Capabilities().Encoders). | |
| 207 | - | Msg("cef: GPU transcoding available") | |
| 208 | - | } else { | |
| 209 | - | transcoderState.Status = "unavailable_no_compatible_gpu" | |
| 210 | - | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | |
| 211 | - | } | |
| 195 | + | messageRouter, schemeHandler, err := newEngineSchemeHandler( | |
| 196 | + | ctx, | |
| 197 | + | eng, | |
| 198 | + | logger, | |
| 199 | + | mediaTranscoder, | |
| 200 | + | currentConfigPayload, | |
| 201 | + | defaultConfigPayload, | |
| 202 | + | ) | |
| 203 | + | if err != nil { | |
| 204 | + | purecef.Shutdown() | |
| 205 | + | return nil, err | |
| 212 | 206 | } | |
| 207 | + | eng.messageRouter = messageRouter | |
| 208 | + | eng.schemeHandler = schemeHandler | |
| 213 | 209 | ||
| 214 | - | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 215 | - | scale: scale, | |
| 216 | - | windowlessFrameRate: windowlessFrameRate, | |
| 217 | - | enableContextMenuHandler: cfg.EnableContextMenuHandler, | |
| 218 | - | transcoder: mediaTranscoder, | |
| 219 | - | mediaClassifier: mediaClassifier, | |
| 220 | - | audioOutputFactory: audioFactory, | |
| 221 | - | }) | |
| 222 | - | pool := newWebViewPool(factory) | |
| 210 | + | registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler)) | |
| 211 | + | return eng, nil | |
| 212 | + | } | |
| 223 | 213 | ||
| 224 | - | eng.gl = gl | |
| 225 | - | eng.factory = factory | |
| 226 | - | eng.pool = pool | |
| 227 | - | eng.contentInj = newContentInjector(eng, nil) | |
| 228 | - | eng.transcoderState = transcoderState | |
| 214 | + | func initializeEngineTranscoder( | |
| 215 | + | transcodingCfg TranscodingRuntimeConfig, | |
| 216 | + | logger *zerolog.Logger, | |
| 217 | + | ) (port.MediaTranscoder, transcoderStartupState) { | |
| 218 | + | transcoderState := buildTranscoderStartupState(transcodingCfg) | |
| 219 | + | if !transcodingCfg.Enabled { | |
| 220 | + | return nil, transcoderState | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | transcoderState.ProbeAttempted = true | |
| 224 | + | tc := transcoderpkg.New(config.TranscodingConfig{ | |
| 225 | + | Enabled: transcodingCfg.Enabled, | |
| 226 | + | HWAccel: transcodingCfg.HWAccel, | |
| 227 | + | MaxConcurrent: transcodingCfg.MaxConcurrent, | |
| 228 | + | Quality: transcodingCfg.Quality, | |
| 229 | + | }, logger) | |
| 230 | + | caps := tc.Capabilities() | |
| 231 | + | transcoderState.API = caps.API | |
| 232 | + | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |
| 233 | + | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |
| 234 | + | if !tc.Available() { | |
| 235 | + | transcoderState.Status = "unavailable_no_compatible_gpu" | |
| 236 | + | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | |
| 237 | + | return nil, transcoderState | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | transcoderState.Status = "available" | |
| 241 | + | logger.Info(). | |
| 242 | + | Str("api", caps.API). | |
| 243 | + | Strs("encoders", caps.Encoders). | |
| 244 | + | Msg("cef: GPU transcoding available") | |
| 245 | + | return tc, transcoderState | |
| 246 | + | } | |
| 229 | 247 | ||
| 248 | + | func newEngineSchemeHandler( | |
| 249 | + | ctx context.Context, | |
| 250 | + | eng *Engine, | |
| 251 | + | logger *zerolog.Logger, | |
| 252 | + | mediaTranscoder port.MediaTranscoder, | |
| 253 | + | currentConfigPayload func() ([]byte, error), | |
| 254 | + | defaultConfigPayload func() ([]byte, error), | |
| 255 | + | ) (*MessageRouter, *dumbSchemeHandler, error) { | |
| 230 | 256 | messageRouter := NewMessageRouter(ctx) | |
| 231 | 257 | schemeHandler, err := newDumbSchemeHandler( | |
| 232 | 258 | ctx, | |
| @@ -236,15 +262,25 @@ func wireEngine( | |||
| 236 | 262 | defaultConfigPayload, | |
| 237 | 263 | ) | |
| 238 | 264 | if err != nil { | |
| 239 | - | purecef.Shutdown() | |
| 240 | - | return nil, err | |
| 265 | + | return nil, nil, err | |
| 241 | 266 | } | |
| 242 | 267 | schemeHandler.setAssets(assets.WebUIAssets) | |
| 268 | + | schemeHandler.onClipboardSet = makeGTKClipboardSetter(logger) | |
| 269 | + | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 270 | + | schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen | |
| 271 | + | schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate | |
| 272 | + | schemeHandler.onPopupClose = eng.handlePopupBridgeClose | |
| 273 | + | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | |
| 274 | + | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | |
| 275 | + | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |
| 276 | + | return messageRouter, schemeHandler, nil | |
| 277 | + | } | |
| 243 | 278 | ||
| 244 | - | // Bridge clipboard writes from CEF JS → GDK system clipboard. | |
| 245 | - | // The callback is invoked on the CEF IO thread, so we schedule the | |
| 246 | - | // GDK write on the GTK main loop via glib.IdleAddOnce. | |
| 247 | - | schemeHandler.onClipboardSet = func(text string) { | |
| 279 | + | func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) { | |
| 280 | + | // Bridge clipboard writes from page JS → GDK system clipboard. | |
| 281 | + | // The callback is invoked on the CEF IO thread, so schedule the actual GDK | |
| 282 | + | // write on the GTK main loop. | |
| 283 | + | return func(text string) { | |
| 248 | 284 | fn := glib.SourceOnceFunc(func(_ uintptr) { | |
| 249 | 285 | if display := gdk.DisplayGetDefault(); display != nil { | |
| 250 | 286 | if cb := display.GetClipboard(); cb != nil { | |
| @@ -259,32 +295,37 @@ func wireEngine( | |||
| 259 | 295 | }) | |
| 260 | 296 | glib.IdleAddOnce(&fn, 0) | |
| 261 | 297 | } | |
| 298 | + | } | |
| 262 | 299 | ||
| 263 | - | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 300 | + | func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) { | |
| 301 | + | registerEngineSchemeFactory(logger, "dumb", "", schemeFactory) | |
| 302 | + | registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory) | |
| 303 | + | } | |
| 264 | 304 | ||
| 265 | - | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | |
| 266 | - | if result != 1 { | |
| 267 | - | logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory") | |
| 268 | - | } else { | |
| 305 | + | func registerEngineSchemeFactory( | |
| 306 | + | logger *zerolog.Logger, | |
| 307 | + | scheme, host string, | |
| 308 | + | schemeFactory purecef.SchemeHandlerFactory, | |
| 309 | + | ) { | |
| 310 | + | result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory) | |
| 311 | + | if scheme == "dumb" && host == "" { | |
| 312 | + | if result != 1 { | |
| 313 | + | logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory") | |
| 314 | + | return | |
| 315 | + | } | |
| 269 | 316 | logger.Info().Msg("cef: registered dumb:// scheme handler factory") | |
| 317 | + | return | |
| 270 | 318 | } | |
| 271 | - | ||
| 272 | - | result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory) | |
| 273 | 319 | if result != 1 { | |
| 274 | 320 | logger.Error(). | |
| 275 | 321 | Int32("result", result). | |
| 276 | 322 | Str("origin", actualInternalOrigin). | |
| 277 | 323 | Msg("cef: failed to register internal https handler factory") | |
| 278 | - | } else { | |
| 279 | - | logger.Info(). | |
| 280 | - | Str("origin", actualInternalOrigin). | |
| 281 | - | Msg("cef: registered internal https handler factory") | |
| 324 | + | return | |
| 282 | 325 | } | |
| 283 | - | ||
| 284 | - | eng.messageRouter = messageRouter | |
| 285 | - | eng.schemeHandler = schemeHandler | |
| 286 | - | ||
| 287 | - | return eng, nil | |
| 326 | + | logger.Info(). | |
| 327 | + | Str("origin", actualInternalOrigin). | |
| 328 | + | Msg("cef: registered internal https handler factory") | |
| 288 | 329 | } | |
| 289 | 330 | ||
| 290 | 331 | func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState { | |
| @@ -405,71 +446,88 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) { | |||
| 405 | 446 | } | |
| 406 | 447 | } | |
| 407 | 448 | ||
| 408 | - | // findHelperBinary looks for the cef-helper binary next to the running | |
| 409 | - | // executable. Returns empty string if not found. | |
| 410 | - | func findHelperBinary() string { | |
| 411 | - | exe, err := os.Executable() | |
| 449 | + | const ( | |
| 450 | + | dirPerm = 0o700 | |
| 451 | + | filePerm = 0o600 | |
| 452 | + | ) | |
| 453 | + | ||
| 454 | + | func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) { | |
| 455 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) | |
| 456 | + | if runtimeLogFile == "" { | |
| 457 | + | return "", nil | |
| 458 | + | } | |
| 459 | + | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 460 | + | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | file, err := openRegularLogFile(runtimeLogFile) | |
| 412 | 464 | if err != nil { | |
| 413 | - | return "" | |
| 465 | + | return "", err | |
| 414 | 466 | } | |
| 415 | - | dir := filepath.Dir(exe) | |
| 416 | - | helper := filepath.Join(dir, "cef-helper") | |
| 417 | - | if _, err := os.Stat(helper); err == nil { | |
| 418 | - | return helper | |
| 467 | + | defer func() { _ = file.Close() }() | |
| 468 | + | ||
| 469 | + | info, err := file.Stat() | |
| 470 | + | if err != nil { | |
| 471 | + | return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err) | |
| 419 | 472 | } | |
| 420 | - | return "" | |
| 473 | + | if !info.Mode().IsRegular() { | |
| 474 | + | return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile) | |
| 475 | + | } | |
| 476 | + | if err := file.Chmod(filePerm); err != nil { | |
| 477 | + | return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err) | |
| 478 | + | } | |
| 479 | + | return runtimeLogFile, nil | |
| 421 | 480 | } | |
| 422 | 481 | ||
| 423 | - | const ( | |
| 424 | - | dirPerm = 0o755 | |
| 425 | - | filePerm = 0o644 | |
| 426 | - | ) | |
| 427 | - | ||
| 428 | - | func prepareCEFLogFile(logFile string) (string, error) { | |
| 429 | - | if logFile != "" { | |
| 430 | - | runtimeLogFile := filepath.Clean(logFile) | |
| 431 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 432 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 482 | + | func openRegularLogFile(path string) (*os.File, error) { | |
| 483 | + | info, err := os.Lstat(path) | |
| 484 | + | if err != nil { | |
| 485 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 486 | + | return nil, fmt.Errorf("lstat %s: %w", path, err) | |
| 487 | + | } | |
| 488 | + | file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm) | |
| 489 | + | if createErr != nil { | |
| 490 | + | return nil, fmt.Errorf("create %s: %w", path, createErr) | |
| 433 | 491 | } | |
| 434 | - | return runtimeLogFile, nil | |
| 492 | + | return file, nil | |
| 435 | 493 | } | |
| 436 | - | ||
| 437 | - | // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD. | |
| 438 | - | // Production deployments should set cfg.LogFile explicitly. | |
| 439 | - | cwd, cwdErr := os.Getwd() | |
| 440 | - | if cwdErr != nil { | |
| 441 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |
| 494 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 495 | + | return nil, fmt.Errorf("log path %s must not be a symlink", path) | |
| 442 | 496 | } | |
| 443 | - | runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 444 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 445 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 497 | + | if !info.Mode().IsRegular() { | |
| 498 | + | return nil, fmt.Errorf("log path %s is not a regular file", path) | |
| 446 | 499 | } | |
| 447 | - | return runtimeLogFile, nil | |
| 500 | + | file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0) | |
| 501 | + | if openErr != nil { | |
| 502 | + | return nil, fmt.Errorf("open %s: %w", path, openErr) | |
| 503 | + | } | |
| 504 | + | return file, nil | |
| 448 | 505 | } | |
| 449 | 506 | ||
| 450 | - | func prepareCEFInitTraceFile(logFile string) (string, error) { | |
| 451 | - | if !puregoCEFInitTraceEnabled() { | |
| 452 | - | return "", nil | |
| 507 | + | func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) { | |
| 508 | + | fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm)) | |
| 509 | + | if err != nil { | |
| 510 | + | return nil, err | |
| 453 | 511 | } | |
| 512 | + | return os.NewFile(uintptr(fd), path), nil | |
| 513 | + | } | |
| 454 | 514 | ||
| 455 | - | runtimeLogFile := logFile | |
| 456 | - | if runtimeLogFile != "" { | |
| 457 | - | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 458 | - | } else { | |
| 459 | - | cwd, cwdErr := os.Getwd() | |
| 460 | - | if cwdErr != nil { | |
| 461 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |
| 462 | - | } | |
| 463 | - | runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 515 | + | func resolveCEFInitTraceFile(defaultLogFile, logFile string) string { | |
| 516 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) | |
| 517 | + | if runtimeLogFile == "" { | |
| 518 | + | return "" | |
| 464 | 519 | } | |
| 465 | - | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 466 | - | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | |
| 467 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(bootstrapLogFile), err) | |
| 520 | + | return strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func resolveLogFile(defaultLogFile, logFile string) string { | |
| 524 | + | if strings.TrimSpace(logFile) != "" { | |
| 525 | + | return filepath.Clean(logFile) | |
| 468 | 526 | } | |
| 469 | - | if err := os.WriteFile(bootstrapLogFile, nil, filePerm); err != nil { | |
| 470 | - | return "", fmt.Errorf("reset %s: %w", bootstrapLogFile, err) | |
| 527 | + | if strings.TrimSpace(defaultLogFile) != "" { | |
| 528 | + | return filepath.Clean(defaultLogFile) | |
| 471 | 529 | } | |
| 472 | - | return bootstrapLogFile, nil | |
| 530 | + | return "" | |
| 473 | 531 | } | |
| 474 | 532 | ||
| 475 | 533 | func puregoCEFInitTraceEnabled() bool { | |
M internal/infrastructure/cef/engine_init_test.go +144 -48
| @@ -1,99 +1,160 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 11 | 12 | "github.com/rs/zerolog" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | - | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | |
| 16 | - | root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 17 | - | require.Equal(t, "/tmp/data", root) | |
| 16 | + | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | |
| 17 | + | profile := testCEFDevProfile(t) | |
| 18 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 19 | + | require.Equal(t, "/tmp/cache", root) | |
| 18 | 20 | } | |
| 19 | 21 | ||
| 20 | - | func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) { | |
| 21 | - | root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"}) | |
| 22 | - | require.Equal(t, "/tmp/cache", root) | |
| 22 | + | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |
| 23 | + | profile := testCEFDevProfile(t) | |
| 24 | + | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | |
| 25 | + | ||
| 26 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 27 | + | require.Equal(t, "/tmp/override", root) | |
| 28 | + | ||
| 29 | + | root = resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 30 | + | require.Equal(t, "/tmp/override", root) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |
| 34 | + | profile := testCEFProdProfile(t) | |
| 35 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 36 | + | require.Equal(t, profile.CEFUserDataDir(), root) | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | - | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | |
| 39 | + | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |
| 26 | 40 | logger := zerolog.Nop() | |
| 27 | - | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | |
| 41 | + | profile := testCEFDevProfile(t) | |
| 42 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 28 | 43 | require.NoError(t, err) | |
| 29 | - | require.Equal(t, "/tmp/cache", settings.RootCachePath) | |
| 44 | + | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | |
| 45 | + | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |
| 46 | + | require.Empty(t, settings.BrowserSubprocessPath) | |
| 30 | 47 | } | |
| 31 | 48 | ||
| 32 | 49 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 33 | 50 | logger := zerolog.Nop() | |
| 34 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger) | |
| 51 | + | profile := testCEFDevProfile(t) | |
| 52 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 35 | 53 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | |
| 36 | 54 | } | |
| 37 | 55 | ||
| 38 | 56 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |
| 39 | 57 | logger := zerolog.Nop() | |
| 40 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger) | |
| 58 | + | profile := testCEFDevProfile(t) | |
| 59 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 41 | 60 | require.NoError(t, err) | |
| 42 | 61 | } | |
| 43 | 62 | ||
| 44 | - | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |
| 45 | - | t.Setenv(puregoCEFInitTraceEnvVar, "") | |
| 63 | + | func TestResolveCEFInitTraceFile_EmptyWithoutLogPath(t *testing.T) { | |
| 64 | + | require.Empty(t, resolveCEFInitTraceFile("", "")) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectoryAndFile(t *testing.T) { | |
| 68 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 46 | 69 | ||
| 47 | - | path, err := prepareCEFInitTraceFile("") | |
| 70 | + | path, err := prepareCEFLogFile("", logFile) | |
| 48 | 71 | require.NoError(t, err) | |
| 49 | - | require.Empty(t, path) | |
| 50 | - | } | |
| 72 | + | require.Equal(t, logFile, path) | |
| 51 | 73 | ||
| 52 | - | func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) { | |
| 53 | - | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 54 | - | cfg := config.CEFEngineConfig{ | |
| 55 | - | LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"), | |
| 56 | - | } | |
| 74 | + | dirInfo, err := os.Stat(filepath.Dir(logFile)) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |
| 57 | 77 | ||
| 58 | - | path, err := prepareCEFInitTraceFile(cfg.LogFile) | |
| 78 | + | fileInfo, err := os.Stat(logFile) | |
| 59 | 79 | require.NoError(t, err) | |
| 60 | - | require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path) | |
| 61 | - | require.FileExists(t, path) | |
| 80 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) | |
| 62 | 81 | } | |
| 63 | 82 | ||
| 64 | - | func TestPrepareCEFSettings_RootCachePath_UsesLegacyCEFUserDataDir(t *testing.T) { | |
| 65 | - | homeDir := filepath.Join(t.TempDir(), "home") | |
| 66 | - | t.Setenv("HOME", homeDir) | |
| 67 | - | t.Setenv("ENV", "") | |
| 83 | + | func TestPrepareCEFLogFile_TightensExistingFilePermissions(t *testing.T) { | |
| 84 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 85 | + | require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm)) | |
| 86 | + | require.NoError(t, os.WriteFile(logFile, []byte("existing"), 0o644)) | |
| 68 | 87 | ||
| 69 | - | logger := zerolog.Nop() | |
| 70 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 88 | + | path, err := prepareCEFLogFile("", logFile) | |
| 71 | 89 | require.NoError(t, err) | |
| 90 | + | require.Equal(t, logFile, path) | |
| 72 | 91 | ||
| 73 | - | require.Equal(t, filepath.Join(homeDir, ".config", "cef_user_data"), settings.RootCachePath) | |
| 92 | + | fileInfo, err := os.Stat(logFile) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) | |
| 74 | 95 | } | |
| 75 | 96 | ||
| 76 | - | func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |
| 77 | - | // Create a temp directory and change into it for the test | |
| 78 | - | tempDir := t.TempDir() | |
| 79 | - | originalDir, err := os.Getwd() | |
| 80 | - | require.NoError(t, err) | |
| 81 | - | defer func() { | |
| 82 | - | _ = os.Chdir(originalDir) | |
| 83 | - | }() | |
| 97 | + | func TestPrepareCEFLogFile_RejectsSymlink(t *testing.T) { | |
| 98 | + | target := filepath.Join(t.TempDir(), "target.log") | |
| 99 | + | require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644)) | |
| 84 | 100 | ||
| 85 | - | err = os.Chdir(tempDir) | |
| 86 | - | require.NoError(t, err) | |
| 101 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 102 | + | require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm)) | |
| 103 | + | require.NoError(t, os.Symlink(target, logFile)) | |
| 87 | 104 | ||
| 88 | - | // Set ENV=dev to trigger dev mode | |
| 89 | - | t.Setenv("ENV", "dev") | |
| 105 | + | path, err := prepareCEFLogFile("", logFile) | |
| 106 | + | require.ErrorContains(t, err, "must not be a symlink") | |
| 107 | + | require.Empty(t, path) | |
| 90 | 108 | ||
| 91 | - | logger := zerolog.Nop() | |
| 92 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 109 | + | targetInfo, statErr := os.Stat(target) | |
| 110 | + | require.NoError(t, statErr) | |
| 111 | + | require.Equal(t, os.FileMode(0o644), targetInfo.Mode().Perm()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestPrepareCEFLogFile_RejectsDirectoryPath(t *testing.T) { | |
| 115 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 116 | + | require.NoError(t, os.MkdirAll(logFile, dirPerm)) | |
| 117 | + | ||
| 118 | + | path, err := prepareCEFLogFile("", logFile) | |
| 119 | + | require.ErrorContains(t, err, "not a regular file") | |
| 120 | + | require.Empty(t, path) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestResolveCEFInitTraceFile_UsesExplicitLogFile(t *testing.T) { | |
| 124 | + | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | |
| 125 | + | ||
| 126 | + | path := resolveCEFInitTraceFile("", logFile) | |
| 127 | + | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestResolveCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | |
| 131 | + | profile := testCEFDevProfile(t) | |
| 132 | + | ||
| 133 | + | path := resolveCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 134 | + | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestPrepareCEFSettings_IgnoresInitTraceRequestWithoutTouchingFilesystem(t *testing.T) { | |
| 138 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 139 | + | profile := testCEFDevProfile(t) | |
| 140 | + | bootstrapLogFile := resolveCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 141 | + | ||
| 142 | + | var logs bytes.Buffer | |
| 143 | + | logger := zerolog.New(&logs) | |
| 144 | + | ||
| 145 | + | settings, err := prepareCEFSettings( | |
| 146 | + | port.EngineOptions{}, | |
| 147 | + | RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, | |
| 148 | + | RuntimeConfig{}, | |
| 149 | + | &logger, | |
| 150 | + | ) | |
| 93 | 151 | require.NoError(t, err) | |
| 152 | + | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |
| 94 | 153 | ||
| 95 | - | // In dev mode, should resolve under .dev/dumber/cef_user_data | |
| 96 | - | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |
| 154 | + | _, statErr := os.Stat(bootstrapLogFile) | |
| 155 | + | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 156 | + | require.Contains(t, logs.String(), puregoCEFInitTraceEnvVar) | |
| 157 | + | require.Contains(t, logs.String(), bootstrapLogFile) | |
| 97 | 158 | } | |
| 98 | 159 | ||
| 99 | 160 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | |
| @@ -104,3 +165,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||
| 104 | 165 | ||
| 105 | 166 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | |
| 106 | 167 | } | |
| 168 | + | ||
| 169 | + | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |
| 170 | + | t.Helper() | |
| 171 | + | cwd := t.TempDir() | |
| 172 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 173 | + | Env: func(key string) string { | |
| 174 | + | if key == "ENV" { | |
| 175 | + | return "dev" | |
| 176 | + | } | |
| 177 | + | return "" | |
| 178 | + | }, | |
| 179 | + | Engine: "cef", | |
| 180 | + | CWD: func() (string, error) { return cwd, nil }, | |
| 181 | + | }) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | return profile | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | |
| 187 | + | t.Helper() | |
| 188 | + | root := t.TempDir() | |
| 189 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 190 | + | Env: func(string) string { return "" }, | |
| 191 | + | Engine: "cef", | |
| 192 | + | CWD: func() (string, error) { return root, nil }, | |
| 193 | + | Base: runtimeprofile.BasePaths{ | |
| 194 | + | ConfigHome: filepath.Join(root, "config"), | |
| 195 | + | DataHome: filepath.Join(root, "data"), | |
| 196 | + | StateHome: filepath.Join(root, "state"), | |
| 197 | + | CacheHome: filepath.Join(root, "cache"), | |
| 198 | + | }, | |
| 199 | + | }) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | return profile | |
| 202 | + | } | |
A internal/infrastructure/cef/engine_popup_bridge.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (e *Engine) webViewForBrowser(browser purecef.Browser) *WebView { | |
| 10 | + | if e == nil || browser == nil { | |
| 11 | + | return nil | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | browserID := browser.GetIdentifier() | |
| 15 | + | if current, ok := e.browserWebViews.Load(browserID); ok { | |
| 16 | + | if wv, ok := current.(*WebView); ok && wv != nil { | |
| 17 | + | wv.mu.RLock() | |
| 18 | + | wvBrowser := wv.browser | |
| 19 | + | wv.mu.RUnlock() | |
| 20 | + | if wvBrowser != nil && wvBrowser.GetIdentifier() == browserID { | |
| 21 | + | return wv | |
| 22 | + | } | |
| 23 | + | e.unbindBrowserWebView(browserID, wv) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var matched *WebView | |
| 28 | + | e.activeWebViews.Range(func(_, value any) bool { | |
| 29 | + | wv, ok := value.(*WebView) | |
| 30 | + | if !ok || wv == nil { | |
| 31 | + | return true | |
| 32 | + | } | |
| 33 | + | wv.mu.RLock() | |
| 34 | + | wvBrowser := wv.browser | |
| 35 | + | wv.mu.RUnlock() | |
| 36 | + | if wvBrowser == nil || wvBrowser.GetIdentifier() != browserID { | |
| 37 | + | return true | |
| 38 | + | } | |
| 39 | + | matched = wv | |
| 40 | + | e.bindBrowserWebView(browser, wv) | |
| 41 | + | return false | |
| 42 | + | }) | |
| 43 | + | return matched | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (e *Engine) withBridgeSourceWebView(browser purecef.Browser, proxyID, action string, fn func(*WebView)) { | |
| 47 | + | if e == nil || browser == nil { | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | wv := e.webViewForBrowser(browser) | |
| 51 | + | if wv == nil { | |
| 52 | + | logging.FromContext(e.currentContext()).Warn(). | |
| 53 | + | Int32("browser_id", browser.GetIdentifier()). | |
| 54 | + | Str("proxy_id", proxyID). | |
| 55 | + | Msg("cef: " + action + " bridge could not resolve source webview") | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | fn(wv) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (e *Engine) handlePopupBridgeOpen(browser purecef.Browser, payload rendererBridgePopupOpenPayload) { | |
| 62 | + | e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-open", func(wv *WebView) { | |
| 63 | + | wv.handleSyntheticPopupOpen(payload.URL, payload.FrameName, payload.ProxyID, payload.UserGesture, payload.NoJavaScriptAccess) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (e *Engine) handlePopupBridgeNavigate(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) { | |
| 68 | + | e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-navigate", func(wv *WebView) { | |
| 69 | + | wv.handleSyntheticPopupNavigate(payload.ProxyID, payload.URL) | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (e *Engine) handlePopupBridgeClose(browser purecef.Browser, payload rendererBridgePopupClosePayload) { | |
| 74 | + | e.withBridgeSourceWebView(browser, payload.ProxyID, "popup-close", func(wv *WebView) { | |
| 75 | + | wv.handleSyntheticPopupClose(payload.ProxyID) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (e *Engine) handlePopupOpenerNavigate(browser purecef.Browser, payload popupOpenerNavigatePayload) { | |
| 80 | + | e.withBridgeSourceWebView(browser, "", "popup-opener-navigate", func(wv *WebView) { | |
| 81 | + | wv.handlePopupOpenerNavigate(payload.URL) | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (e *Engine) handlePopupOpenerPostMessage(browser purecef.Browser, payload popupOpenerPostMessagePayload) { | |
| 86 | + | e.withBridgeSourceWebView(browser, "", "popup-opener-post-message", func(wv *WebView) { | |
| 87 | + | wv.handlePopupOpenerPostMessage(payload) | |
| 88 | + | }) | |
| 89 | + | } | |
M internal/infrastructure/cef/engine_runtime_test.go +52 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| @@ -51,3 +52,54 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||
| 51 | 52 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | |
| 52 | 53 | require.ErrorIs(t, err, want) | |
| 53 | 54 | } | |
| 55 | + | ||
| 56 | + | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | |
| 57 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 58 | + | onCopied := func(int) {} | |
| 59 | + | eng := &Engine{ | |
| 60 | + | messageRouter: NewMessageRouter(context.Background()), | |
| 61 | + | registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error { | |
| 62 | + | return nil | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{ | |
| 67 | + | ClipboardTextOrchestrator: orchestrator, | |
| 68 | + | OnClipboardCopied: onCopied, | |
| 69 | + | })) | |
| 70 | + | require.Same(t, orchestrator, eng.clipboardTextOrchestrator) | |
| 71 | + | require.NotNil(t, eng.onClipboardCopied) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) { | |
| 75 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 76 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |
| 77 | + | ||
| 78 | + | eng.handleClipboardSelectionUpdate(99, "selected") | |
| 79 | + | ||
| 80 | + | require.Equal(t, dto.SelectionClipboardInput{Text: "selected", SourceEngine: dto.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |
| 84 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 85 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |
| 86 | + | ||
| 87 | + | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |
| 88 | + | ||
| 89 | + | require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | type recordingClipboardTextOrchestrator struct { | |
| 93 | + | selection dto.SelectionClipboardInput | |
| 94 | + | explicit dto.ExplicitClipboardInput | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | |
| 98 | + | r.selection = input | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error { | |
| 103 | + | r.explicit = input | |
| 104 | + | return nil | |
| 105 | + | } | |
M internal/infrastructure/cef/factory.go +266 -110
| @@ -2,7 +2,9 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "sync/atomic" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 10 | ||
| @@ -17,27 +19,37 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||
| 17 | 19 | // gets a unique ID, its own renderPipeline and inputBridge, and an | |
| 18 | 20 | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | |
| 19 | 21 | type WebViewFactory struct { | |
| 20 | - | engine *Engine | |
| 21 | - | gl *glLoader | |
| 22 | - | nextID atomic.Uint64 | |
| 23 | - | scale int32 | |
| 24 | - | windowlessFrameRate int32 | |
| 25 | - | enableContextMenuHandler bool | |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | - | transcoder port.MediaTranscoder | |
| 28 | - | mediaClassifier MediaClassifier | |
| 29 | - | audioOutputFactory port.AudioOutputFactory | |
| 22 | + | engine *Engine | |
| 23 | + | gl *glLoader | |
| 24 | + | nextID atomic.Uint64 | |
| 25 | + | scale int32 | |
| 26 | + | windowlessFrameRate int32 | |
| 27 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 28 | + | transcoder port.MediaTranscoder | |
| 29 | + | mediaClassifier MediaClassifier | |
| 30 | + | audioOutputFactory port.AudioOutputFactory | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | type webViewFactoryOptions struct { | |
| 33 | - | scale int32 | |
| 34 | - | windowlessFrameRate int32 | |
| 35 | - | enableContextMenuHandler bool | |
| 36 | - | transcoder port.MediaTranscoder | |
| 37 | - | mediaClassifier MediaClassifier | |
| 38 | - | audioOutputFactory port.AudioOutputFactory | |
| 34 | + | scale int32 | |
| 35 | + | windowlessFrameRate int32 | |
| 36 | + | transcoder port.MediaTranscoder | |
| 37 | + | mediaClassifier MediaClassifier | |
| 38 | + | audioOutputFactory port.AudioOutputFactory | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | type resizeNotifiableBrowserHost interface { | |
| 42 | + | WasResized() | |
| 43 | + | Invalidate(purecef.PaintElementType) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser | |
| 47 | + | ||
| 48 | + | const ( | |
| 49 | + | pendingBrowserCreateRetryDelay = 10 * time.Millisecond | |
| 50 | + | pendingBrowserCreateMaxPostRetries = 3 | |
| 51 | + | ) | |
| 52 | + | ||
| 41 | 53 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 42 | 54 | // given GL loader and HiDPI scale factor. | |
| 43 | 55 | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | |
| @@ -48,14 +60,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 48 | 60 | opts.windowlessFrameRate = 60 | |
| 49 | 61 | } | |
| 50 | 62 | return &WebViewFactory{ | |
| 51 | - | engine: engine, | |
| 52 | - | gl: gl, | |
| 53 | - | scale: opts.scale, | |
| 54 | - | windowlessFrameRate: opts.windowlessFrameRate, | |
| 55 | - | enableContextMenuHandler: opts.enableContextMenuHandler, | |
| 56 | - | transcoder: opts.transcoder, | |
| 57 | - | mediaClassifier: opts.mediaClassifier, | |
| 58 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 63 | + | engine: engine, | |
| 64 | + | gl: gl, | |
| 65 | + | scale: opts.scale, | |
| 66 | + | windowlessFrameRate: opts.windowlessFrameRate, | |
| 67 | + | transcoder: opts.transcoder, | |
| 68 | + | mediaClassifier: opts.mediaClassifier, | |
| 69 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 59 | 70 | } | |
| 60 | 71 | } | |
| 61 | 72 | ||
| @@ -70,16 +81,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |||
| 70 | 81 | // asynchronously; the returned WebView is usable immediately but navigation | |
| 71 | 82 | // will fail with errNoBrowser until OnAfterCreated fires. | |
| 72 | 83 | func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |
| 73 | - | id := port.WebViewID(f.nextID.Add(1)) | |
| 84 | + | wv := f.newWebView(ctx) | |
| 85 | + | ||
| 86 | + | // Configure WindowInfo for off-screen rendering (OSR). | |
| 87 | + | windowInfo := purecef.NewWindowInfo() | |
| 88 | + | windowInfo.WindowlessRenderingEnabled = 1 | |
| 89 | + | if externalBeginFrameEnabled() { | |
| 90 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Configure BrowserSettings. | |
| 94 | + | settings := purecef.NewBrowserSettings() | |
| 95 | + | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 96 | + | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 97 | + | if bg := f.bgColor.Load(); bg != 0 { | |
| 98 | + | settings.BackgroundColor = bg | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Defer browser creation until the GL area has a non-zero size. | |
| 102 | + | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 103 | + | // is not yet realized at this point. | |
| 104 | + | f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings) | |
| 105 | + | ||
| 106 | + | return wv, nil | |
| 107 | + | } | |
| 74 | 108 | ||
| 75 | - | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | |
| 109 | + | func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |
| 110 | + | id := port.WebViewID(f.nextID.Add(1)) | |
| 111 | + | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) | |
| 76 | 112 | ||
| 77 | 113 | wv := &WebView{ | |
| 78 | - | id: id, | |
| 79 | - | ctx: ctx, | |
| 80 | - | engine: f.engine, | |
| 81 | - | pipeline: pipeline, | |
| 82 | - | audioOutputFactory: f.audioOutputFactory, | |
| 114 | + | id: id, | |
| 115 | + | ctx: ctx, | |
| 116 | + | engine: f.engine, | |
| 117 | + | pipeline: pipeline, | |
| 118 | + | audioOutputFactory: f.audioOutputFactory, | |
| 119 | + | resizeReconciler: newResizeReconciler(ctx, id), | |
| 120 | + | windowlessFrameRate: f.windowlessFrameRate, | |
| 121 | + | backgroundColor: f.bgColor.Load(), | |
| 83 | 122 | } | |
| 84 | 123 | ||
| 85 | 124 | var transcodingHandler purecef.ResourceRequestHandler | |
| @@ -93,14 +132,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 93 | 132 | } | |
| 94 | 133 | ||
| 95 | 134 | handlers := &handlerSet{ | |
| 96 | - | wv: wv, | |
| 97 | - | enableContextMenuHandler: f.enableContextMenuHandler, | |
| 98 | - | transcodingHandler: transcodingHandler, | |
| 135 | + | wv: wv, | |
| 136 | + | transcodingHandler: transcodingHandler, | |
| 99 | 137 | } | |
| 100 | 138 | wv.handlers = handlers | |
| 101 | 139 | wv.findCtrl = newFindController() | |
| 102 | 140 | ||
| 103 | 141 | input := newInputBridge(ctx, f.scale) | |
| 142 | + | input.selectionText = wv.selectedTextSnapshot | |
| 143 | + | input.explicitCopyText = func(action, text string) { | |
| 144 | + | if f.engine != nil { | |
| 145 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 146 | + | } | |
| 147 | + | } | |
| 104 | 148 | input.attachTo(pipeline.glArea) | |
| 105 | 149 | wv.input = input | |
| 106 | 150 | ||
| @@ -121,34 +165,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 121 | 165 | ||
| 122 | 166 | // Build a CEF client backed by our handlerSet. | |
| 123 | 167 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 124 | - | client := purecef.NewClient(wv.handlers) | |
| 125 | - | wv.client = client | |
| 126 | - | ||
| 127 | - | // Configure WindowInfo for off-screen rendering (OSR). | |
| 128 | - | windowInfo := purecef.NewWindowInfo() | |
| 129 | - | windowInfo.WindowlessRenderingEnabled = 1 | |
| 130 | - | if externalBeginFrameEnabled() { | |
| 131 | - | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Configure BrowserSettings. | |
| 135 | - | settings := purecef.NewBrowserSettings() | |
| 136 | - | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 137 | - | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 138 | - | if bg := f.bgColor.Load(); bg != 0 { | |
| 139 | - | settings.BackgroundColor = bg | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Defer browser creation until the GL area has a non-zero size. | |
| 143 | - | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 144 | - | // is not yet realized at this point. | |
| 145 | - | f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings) | |
| 146 | - | ||
| 147 | - | return wv, nil | |
| 168 | + | wv.client = purecef.NewClient(wv.handlers) | |
| 169 | + | return wv | |
| 148 | 170 | } | |
| 149 | 171 | ||
| 150 | 172 | func (f *WebViewFactory) configureInitialBrowserCreation( | |
| 151 | - | ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.Client, | |
| 173 | + | ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.RawClient, | |
| 152 | 174 | windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings, | |
| 153 | 175 | ) { | |
| 154 | 176 | wv.pendingCreate = &pendingBrowserCreate{ | |
| @@ -158,66 +180,200 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 158 | 180 | } | |
| 159 | 181 | ||
| 160 | 182 | pipeline.onFirstResize = func(w, h int32) { | |
| 183 | + | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 184 | + | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | |
| 188 | + | pipeline.onResizeCB = func(w, h int32) { | |
| 161 | 189 | log := logging.FromContext(ctx) | |
| 162 | - | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 163 | - | pc := wv.takePendingCreate() | |
| 164 | - | if pc == nil { | |
| 190 | + | wv.mu.RLock() | |
| 191 | + | host := wv.host | |
| 192 | + | wv.mu.RUnlock() | |
| 193 | + | if host == nil { | |
| 194 | + | log.Debug(). | |
| 195 | + | Uint64("webview_id", uint64(wv.id)). | |
| 196 | + | Int32("resize_width", w). | |
| 197 | + | Int32("resize_height", h). | |
| 198 | + | Bool("host_nil", true). | |
| 199 | + | Bool("browser_ready", false). | |
| 200 | + | Msg("cef: resize observed before browser host existed") | |
| 165 | 201 | return | |
| 166 | 202 | } | |
| 167 | - | ||
| 168 | - | task := purecef.NewTask(cefTaskFunc(func() { | |
| 169 | - | if wv.destroyed.Load() { | |
| 170 | - | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 171 | - | return | |
| 172 | - | } | |
| 173 | - | result := purecef.BrowserHostCreateBrowser( | |
| 174 | - | pc.windowInfo, | |
| 175 | - | pc.client, | |
| 176 | - | "about:blank", | |
| 177 | - | pc.settings, | |
| 178 | - | nil, // extraInfo | |
| 179 | - | nil, // requestContext | |
| 203 | + | log.Debug(). | |
| 204 | + | Uint64("webview_id", uint64(wv.id)). | |
| 205 | + | Int32("resize_width", w). | |
| 206 | + | Int32("resize_height", h). | |
| 207 | + | Bool("host_nil", false). | |
| 208 | + | Bool("browser_ready", true). | |
| 209 | + | Msg("cef: browser resize observed before WasResized") | |
| 210 | + | notifyBrowserResize(host) | |
| 211 | + | log.Debug(). | |
| 212 | + | Uint64("webview_id", uint64(wv.id)). | |
| 213 | + | Int32("resize_width", w). | |
| 214 | + | Int32("resize_height", h). | |
| 215 | + | Bool("host_nil", false). | |
| 216 | + | Bool("browser_ready", true). | |
| 217 | + | Msg("cef: browser host WasResized invoked") | |
| 218 | + | if wv.resizeReconciler != nil { | |
| 219 | + | resizeSeq, _ := wv.pipeline.latestResizeDiagnostics() | |
| 220 | + | wv.resizeReconciler.start( | |
| 221 | + | resizeSeq, | |
| 222 | + | func() resizeNotifiableBrowserHost { | |
| 223 | + | wv.mu.RLock() | |
| 224 | + | defer wv.mu.RUnlock() | |
| 225 | + | return wv.host | |
| 226 | + | }, | |
| 227 | + | func() bool { return wv.destroyed.Load() }, | |
| 180 | 228 | ) | |
| 181 | - | if f.engine != nil { | |
| 182 | - | f.engine.recordBrowserCreateRequest(w, h, result) | |
| 183 | - | } | |
| 184 | - | log.Debug(). | |
| 185 | - | Int32("result", result). | |
| 186 | - | Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled). | |
| 187 | - | Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate). | |
| 188 | - | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | |
| 189 | - | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | |
| 190 | - | Bool("client_nil", pc.client == nil). | |
| 191 | - | Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread") | |
| 192 | - | })) | |
| 193 | - | ||
| 194 | - | postResult := purecef.PostTask(purecef.ThreadIDTidUi, task) | |
| 195 | - | if postResult != 1 { | |
| 196 | - | wv.mu.Lock() | |
| 197 | - | if wv.pendingCreate == nil { | |
| 198 | - | wv.pendingCreate = pc | |
| 199 | - | } | |
| 200 | - | wv.mu.Unlock() | |
| 201 | - | log.Error(). | |
| 202 | - | Int32("result", postResult). | |
| 203 | - | Msg("cef: failed to post initial browser creation to CEF UI thread") | |
| 204 | 229 | } | |
| 205 | 230 | } | |
| 231 | + | } | |
| 206 | 232 | ||
| 207 | - | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | |
| 208 | - | pipeline.onResizeCB = func(_, _ int32) { | |
| 209 | - | wv.mu.RLock() | |
| 210 | - | host := wv.host | |
| 211 | - | wv.mu.RUnlock() | |
| 212 | - | if host != nil { | |
| 213 | - | host.WasResized() | |
| 233 | + | func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) { | |
| 234 | + | log := logging.FromContext(ctx) | |
| 235 | + | pc := wv.takePendingCreate() | |
| 236 | + | if pc == nil { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 241 | + | if wv.destroyed.Load() { | |
| 242 | + | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | pendingURL := wv.pendingNavigationURI() | |
| 246 | + | // Always bootstrap OSR browsers on about:blank first. Starting directly on | |
| 247 | + | // the target URL can race host visibility/focus setup and strand the | |
| 248 | + | // renderer without an initial paint. We replay pending navigation from | |
| 249 | + | // OnAfterCreated once the host is fully wired. | |
| 250 | + | initialURL := "about:blank" | |
| 251 | + | result := cefBrowserHostCreateBrowser( | |
| 252 | + | pc.windowInfo, | |
| 253 | + | pc.client, | |
| 254 | + | initialURL, | |
| 255 | + | pc.settings, | |
| 256 | + | pc.extraInfo, | |
| 257 | + | nil, // requestContext | |
| 258 | + | ) | |
| 259 | + | log.Debug(). | |
| 260 | + | Str("initial_url", initialURL). | |
| 261 | + | Str("pending_url", pendingURL). | |
| 262 | + | Msg("cef: BrowserHostCreateBrowser initial URL") | |
| 263 | + | if f.engine != nil { | |
| 264 | + | f.engine.recordBrowserCreateRequest(w, h, result) | |
| 214 | 265 | } | |
| 266 | + | log.Debug(). | |
| 267 | + | Int32("result", result). | |
| 268 | + | Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled). | |
| 269 | + | Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate). | |
| 270 | + | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | |
| 271 | + | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | |
| 272 | + | Bool("client_nil", pc.client == nil). | |
| 273 | + | Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread") | |
| 274 | + | })) | |
| 275 | + | ||
| 276 | + | postResult := cefPostTask(purecef.ThreadIDTidUi, task) | |
| 277 | + | if postResult != 1 { | |
| 278 | + | pc.postTaskRetries++ | |
| 279 | + | wv.mu.Lock() | |
| 280 | + | wv.pendingCreate = pc | |
| 281 | + | wv.mu.Unlock() | |
| 282 | + | log.Error(). | |
| 283 | + | Int32("result", postResult). | |
| 284 | + | Int("retry", pc.postTaskRetries). | |
| 285 | + | Msg("cef: failed to post initial browser creation to CEF UI thread") | |
| 286 | + | f.schedulePendingBrowserCreateRetry(ctx, wv, w, h, pc) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (f *WebViewFactory) schedulePendingBrowserCreateRetry( | |
| 291 | + | ctx context.Context, | |
| 292 | + | wv *WebView, | |
| 293 | + | w, h int32, | |
| 294 | + | pc *pendingBrowserCreate, | |
| 295 | + | ) { | |
| 296 | + | if f == nil || wv == nil || pc == nil || wv.destroyed.Load() { | |
| 297 | + | return | |
| 215 | 298 | } | |
| 299 | + | if pc.postTaskRetries > pendingBrowserCreateMaxPostRetries { | |
| 300 | + | logging.FromContext(ctx).Warn(). | |
| 301 | + | Uint64("webview_id", uint64(wv.id)). | |
| 302 | + | Int("retries", pc.postTaskRetries). | |
| 303 | + | Int("max_retries", pendingBrowserCreateMaxPostRetries). | |
| 304 | + | Msg("cef: browser creation retries exhausted") | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | cefScheduleAfter(pendingBrowserCreateRetryDelay, func() { | |
| 308 | + | if wv.destroyed.Load() { | |
| 309 | + | return | |
| 310 | + | } | |
| 311 | + | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 312 | + | }) | |
| 216 | 313 | } | |
| 217 | 314 | ||
| 218 | - | // CreateRelated creates a WebView that shares session/cookies with the parent. | |
| 219 | - | // TODO(phase2): look up the parent browser by parentID and use the same | |
| 220 | - | // request context so cookies and session state are shared. | |
| 221 | - | func (f *WebViewFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) { | |
| 222 | - | return nil, ErrRelatedWebViewUnsupported | |
| 315 | + | func notifyBrowserResize(host resizeNotifiableBrowserHost) { | |
| 316 | + | if host == nil { | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | host.WasResized() | |
| 320 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // CreateRelated creates a popup shell for a native CEF popup. The popup | |
| 324 | + | // browser itself is created by CEF from OnBeforePopup and later bound to this | |
| 325 | + | // shell in OnAfterCreated. | |
| 326 | + | func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) { | |
| 327 | + | if f == nil || f.engine == nil { | |
| 328 | + | return nil, ErrRelatedWebViewUnsupported | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | parent := f.engine.lookupWebView(parentID) | |
| 332 | + | if parent == nil { | |
| 333 | + | return nil, fmt.Errorf("parent webview %d not found", parentID) | |
| 334 | + | } | |
| 335 | + | if parent.destroyed.Load() { | |
| 336 | + | return nil, fmt.Errorf("parent webview %d is destroyed", parentID) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | popupWV := f.newWebView(ctx) | |
| 340 | + | popupWV.markNativePopupCandidate(parent) | |
| 341 | + | ||
| 342 | + | windowInfo := purecef.NewWindowInfo() | |
| 343 | + | windowInfo.WindowlessRenderingEnabled = 1 | |
| 344 | + | if externalBeginFrameEnabled() { | |
| 345 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | settings := purecef.NewBrowserSettings() | |
| 349 | + | settings.WindowlessFrameRate = f.windowlessFrameRate | |
| 350 | + | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 351 | + | if bg := f.bgColor.Load(); bg != 0 { | |
| 352 | + | settings.BackgroundColor = bg | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings) | |
| 356 | + | popupWV.pipeline.onFirstResize = func(w, h int32) { | |
| 357 | + | log := logging.FromContext(ctx) | |
| 358 | + | if popupWV.awaitsNativePopupAttachment() { | |
| 359 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach") | |
| 360 | + | popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() { | |
| 361 | + | if !popupWV.startNativePopupFallback() { | |
| 362 | + | return | |
| 363 | + | } | |
| 364 | + | logging.FromContext(ctx).Warn(). | |
| 365 | + | Uint64("webview_id", uint64(popupWV.id)). | |
| 366 | + | Msg("cef: native popup attach timed out, creating popup browser directly") | |
| 367 | + | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 368 | + | }) | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | if !popupWV.preparePopupShellDirectBrowserCreation() { | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly") | |
| 376 | + | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 377 | + | } | |
| 378 | + | return popupWV, nil | |
| 223 | 379 | } | |
A internal/infrastructure/cef/factory_resize_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type resizeOrderHost struct { | |
| 13 | + | calls []string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (h *resizeOrderHost) WasResized() { | |
| 17 | + | h.calls = append(h.calls, "WasResized") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (h *resizeOrderHost) Invalidate(_ purecef.PaintElementType) { | |
| 21 | + | h.calls = append(h.calls, "Invalidate") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) { | |
| 25 | + | host := &resizeOrderHost{} | |
| 26 | + | ||
| 27 | + | notifyBrowserResize(host) | |
| 28 | + | ||
| 29 | + | require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) { | |
| 33 | + | oldNewTask := cefNewTask | |
| 34 | + | oldPostTask := cefPostTask | |
| 35 | + | oldScheduleAfter := cefScheduleAfter | |
| 36 | + | oldCreateBrowser := cefBrowserHostCreateBrowser | |
| 37 | + | defer func() { | |
| 38 | + | cefNewTask = oldNewTask | |
| 39 | + | cefPostTask = oldPostTask | |
| 40 | + | cefScheduleAfter = oldScheduleAfter | |
| 41 | + | cefBrowserHostCreateBrowser = oldCreateBrowser | |
| 42 | + | }() | |
| 43 | + | ||
| 44 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 45 | + | ||
| 46 | + | postCalls := 0 | |
| 47 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 48 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 49 | + | require.NotNil(t, task) | |
| 50 | + | postCalls++ | |
| 51 | + | if postCalls == 1 { | |
| 52 | + | return 0 | |
| 53 | + | } | |
| 54 | + | task.Execute() | |
| 55 | + | return 1 | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | retried := false | |
| 59 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |
| 60 | + | require.Equal(t, pendingBrowserCreateRetryDelay, delay) | |
| 61 | + | retried = true | |
| 62 | + | fn() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | createCalls := 0 | |
| 66 | + | cefBrowserHostCreateBrowser = func( | |
| 67 | + | windowInfo *purecef.WindowInfo, | |
| 68 | + | _ purecef.RawClient, | |
| 69 | + | url string, | |
| 70 | + | settings *purecef.BrowserSettings, | |
| 71 | + | _ purecef.DictionaryValue, | |
| 72 | + | _ purecef.RequestContext, | |
| 73 | + | ) int32 { | |
| 74 | + | createCalls++ | |
| 75 | + | require.NotNil(t, windowInfo) | |
| 76 | + | require.NotNil(t, settings) | |
| 77 | + | require.Equal(t, "about:blank", url) | |
| 78 | + | return 1 | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | windowInfo := purecef.NewWindowInfo() | |
| 82 | + | settings := purecef.NewBrowserSettings() | |
| 83 | + | wv := &WebView{ | |
| 84 | + | ctx: context.Background(), | |
| 85 | + | pendingCreate: &pendingBrowserCreate{ | |
| 86 | + | windowInfo: &windowInfo, | |
| 87 | + | settings: &settings, | |
| 88 | + | }, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | factory := &WebViewFactory{} | |
| 92 | + | factory.postPendingBrowserCreate(context.Background(), wv, 640, 480) | |
| 93 | + | ||
| 94 | + | require.True(t, retried) | |
| 95 | + | require.Equal(t, 2, postCalls) | |
| 96 | + | require.Equal(t, 1, createCalls) | |
| 97 | + | require.Nil(t, wv.takePendingCreate()) | |
| 98 | + | } | |
M internal/infrastructure/cef/gl_loader.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "unsafe" | |
| 6 | 6 | ||
| 7 | - | "github.com/ebitengine/purego" | |
| 7 | + | "github.com/bnema/purego" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // OpenGL constants for PBO-based texture rendering. | |
M internal/infrastructure/cef/input_bridge.go +86 -4
| @@ -3,6 +3,8 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "unicode/utf8" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 10 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -56,6 +58,14 @@ type inputBridge struct { | |||
| 56 | 58 | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 57 | 59 | clipboard *gdk.Clipboard | |
| 58 | 60 | ||
| 61 | + | // selectionText returns the browser's latest known text selection so Ctrl+C | |
| 62 | + | // / Ctrl+X can route the selection through the shared explicit-copy path. | |
| 63 | + | selectionText func() string | |
| 64 | + | ||
| 65 | + | // explicitCopyText routes explicit copy/cut text through the shared | |
| 66 | + | // clipboard orchestration path. | |
| 67 | + | explicitCopyText func(action, text string) | |
| 68 | + | ||
| 59 | 69 | // IMContext for dead key / compose sequence support. Held as a field | |
| 60 | 70 | // to prevent garbage collection while the controller is alive. | |
| 61 | 71 | imContext *gtk.IMContextSimple | |
| @@ -67,12 +77,32 @@ type inputBridge struct { | |||
| 67 | 77 | // show the text caret. | |
| 68 | 78 | glArea *gtk.GLArea | |
| 69 | 79 | ||
| 80 | + | // gtkFocused mirrors the latest GTK focus-controller state so non-GTK | |
| 81 | + | // threads (CEF callbacks, scheme handlers) can safely know whether this | |
| 82 | + | // widget is still the active focused view. | |
| 83 | + | gtkFocused atomic.Bool | |
| 84 | + | ||
| 70 | 85 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | |
| 71 | 86 | // The callback receives the hovered URI. Set by the factory. | |
| 72 | 87 | onMiddleClick func(uri string) | |
| 73 | 88 | } | |
| 74 | 89 | ||
| 75 | 90 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | |
| 91 | + | type browserFocusSyncHost interface { | |
| 92 | + | WasHidden(hidden int32) | |
| 93 | + | SetFocus(focus int32) | |
| 94 | + | Invalidate(elementType purecef.PaintElementType) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func syncWindowlessBrowserFocus(host browserFocusSyncHost) { | |
| 98 | + | if host == nil { | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | host.WasHidden(0) | |
| 102 | + | host.SetFocus(1) | |
| 103 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 104 | + | } | |
| 105 | + | ||
| 76 | 106 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | |
| 77 | 107 | return &inputBridge{ctx: ctx, scale: scale} | |
| 78 | 108 | } | |
| @@ -163,6 +193,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 163 | 193 | // Focus — forward to CEF and IMContext. | |
| 164 | 194 | focus := gtk.NewEventControllerFocus() | |
| 165 | 195 | focusEnterCb := func(_ gtk.EventControllerFocus) { | |
| 196 | + | ib.gtkFocused.Store(true) | |
| 166 | 197 | if ib.imContext != nil { | |
| 167 | 198 | ib.imContext.FocusIn() | |
| 168 | 199 | } | |
| @@ -170,6 +201,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 170 | 201 | } | |
| 171 | 202 | focus.ConnectEnter(&focusEnterCb) | |
| 172 | 203 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | |
| 204 | + | ib.gtkFocused.Store(false) | |
| 173 | 205 | if ib.imContext != nil { | |
| 174 | 206 | ib.imContext.Reset() | |
| 175 | 207 | ib.imContext.FocusOut() | |
| @@ -200,6 +232,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 200 | 232 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 201 | 233 | mods := uint(state) | |
| 202 | 234 | ||
| 235 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |
| 236 | + | ||
| 203 | 237 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 204 | 238 | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 205 | 239 | // ImeCommitText instead of letting CEF handle the paste. | |
| @@ -285,6 +319,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||
| 285 | 319 | return | |
| 286 | 320 | } | |
| 287 | 321 | ||
| 322 | + | // Reassert windowless browser focus before forwarding left-clicks. GTK may | |
| 323 | + | // still consider the GLArea focused while Chromium forgot that state after a | |
| 324 | + | // navigation or DOM transition, which prevents the caret from painting. | |
| 325 | + | if button == 1 { | |
| 326 | + | syncWindowlessBrowserFocus(host) | |
| 327 | + | } | |
| 328 | + | ||
| 288 | 329 | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 289 | 330 | btn := translateMouseButton(button) | |
| 290 | 331 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | |
| @@ -321,10 +362,7 @@ func (ib *inputBridge) onFocusIn() { | |||
| 321 | 362 | ib.mu.Lock() | |
| 322 | 363 | host := ib.host | |
| 323 | 364 | ib.mu.Unlock() | |
| 324 | - | if host == nil { | |
| 325 | - | return | |
| 326 | - | } | |
| 327 | - | host.SetFocus(1) | |
| 365 | + | syncWindowlessBrowserFocus(host) | |
| 328 | 366 | } | |
| 329 | 367 | ||
| 330 | 368 | func (ib *inputBridge) onFocusOut() { | |
| @@ -337,6 +375,46 @@ func (ib *inputBridge) onFocusOut() { | |||
| 337 | 375 | host.SetFocus(0) | |
| 338 | 376 | } | |
| 339 | 377 | ||
| 378 | + | func (ib *inputBridge) hasGTKFocus() bool { | |
| 379 | + | if ib == nil { | |
| 380 | + | return false | |
| 381 | + | } | |
| 382 | + | return ib.gtkFocused.Load() | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |
| 386 | + | action, ok := clipboardShortcutAction(keyval, mods) | |
| 387 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | text := ib.selectionText() | |
| 391 | + | if text == "" { | |
| 392 | + | return | |
| 393 | + | } | |
| 394 | + | logging.FromContext(ib.ctx).Debug(). | |
| 395 | + | Str("action", action). | |
| 396 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 397 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |
| 398 | + | ib.explicitCopyText(action, text) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |
| 402 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |
| 403 | + | return "", false | |
| 404 | + | } | |
| 405 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |
| 406 | + | return "", false | |
| 407 | + | } | |
| 408 | + | switch keyval { | |
| 409 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |
| 410 | + | return "copy", true | |
| 411 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |
| 412 | + | return "cut", true | |
| 413 | + | default: | |
| 414 | + | return "", false | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | ||
| 340 | 418 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |
| 341 | 419 | ib.mu.Lock() | |
| 342 | 420 | host := ib.host | |
| @@ -558,8 +636,12 @@ const ( | |||
| 558 | 636 | gdkKeyPageDown = 0xff56 | |
| 559 | 637 | ||
| 560 | 638 | // Clipboard shortcut keyvals. | |
| 639 | + | gdkKeyLowercaseC = 0x063 | |
| 640 | + | gdkKeyUppercaseC = 0x043 | |
| 561 | 641 | gdkKeyLowercaseV = 0x076 | |
| 562 | 642 | gdkKeyUppercaseV = 0x056 | |
| 643 | + | gdkKeyLowercaseX = 0x078 | |
| 644 | + | gdkKeyUppercaseX = 0x058 | |
| 563 | 645 | ) | |
| 564 | 646 | ||
| 565 | 647 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
A internal/infrastructure/cef/input_bridge_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type focusSyncHostRecorder struct { | |
| 13 | + | calls []string | |
| 14 | + | hiddenValues []int32 | |
| 15 | + | focusValues []int32 | |
| 16 | + | invalidations []purecef.PaintElementType | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (h *focusSyncHostRecorder) WasHidden(hidden int32) { | |
| 20 | + | h.calls = append(h.calls, "WasHidden") | |
| 21 | + | h.hiddenValues = append(h.hiddenValues, hidden) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (h *focusSyncHostRecorder) SetFocus(focus int32) { | |
| 25 | + | h.calls = append(h.calls, "SetFocus") | |
| 26 | + | h.focusValues = append(h.focusValues, focus) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |
| 30 | + | h.calls = append(h.calls, "Invalidate") | |
| 31 | + | h.invalidations = append(h.invalidations, elementType) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) { | |
| 35 | + | host := &focusSyncHostRecorder{} | |
| 36 | + | ||
| 37 | + | syncWindowlessBrowserFocus(host) | |
| 38 | + | ||
| 39 | + | require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls) | |
| 40 | + | require.Equal(t, []int32{0}, host.hiddenValues) | |
| 41 | + | require.Equal(t, []int32{1}, host.focusValues) | |
| 42 | + | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, "copy", action) | |
| 49 | + | ||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |
| 51 | + | require.True(t, ok) | |
| 52 | + | require.Equal(t, "cut", action) | |
| 53 | + | ||
| 54 | + | _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |
| 55 | + | require.False(t, ok) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) { | |
| 59 | + | ib := newInputBridge(context.Background(), 1) | |
| 60 | + | ib.selectionText = func() string { return "selected text" } | |
| 61 | + | routed := struct { | |
| 62 | + | action string | |
| 63 | + | text string | |
| 64 | + | }{} | |
| 65 | + | ib.explicitCopyText = func(action, text string) { | |
| 66 | + | routed.action = action | |
| 67 | + | routed.text = text | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 71 | + | ||
| 72 | + | require.Equal(t, "copy", routed.action) | |
| 73 | + | require.Equal(t, "selected text", routed.text) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) { | |
| 77 | + | ib := newInputBridge(context.Background(), 1) | |
| 78 | + | ib.selectionText = func() string { return "" } | |
| 79 | + | called := false | |
| 80 | + | ib.explicitCopyText = func(string, string) { called = true } | |
| 81 | + | ||
| 82 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue)) | |
| 83 | + | ||
| 84 | + | require.False(t, called) | |
| 85 | + | } | |
M internal/infrastructure/cef/internal_urls.go +33 -1
| @@ -11,6 +11,8 @@ const ( | |||
| 11 | 11 | actualInternalScheme = "https" | |
| 12 | 12 | actualInternalHost = "dumber.invalid" | |
| 13 | 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | |
| 14 | + | internalAPIPathPrefix = "/api/" | |
| 15 | + | internalAPIPathPrefixTrimmed = "api/" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func isConceptualInternalURL(raw string) bool { | |
| @@ -49,7 +51,7 @@ func toActualInternalURL(raw string) string { | |||
| 49 | 51 | case trimmedPath == "": | |
| 50 | 52 | // Keep page roots at /history instead of /history/ so relative assets | |
| 51 | 53 | // resolve from the origin root. | |
| 52 | - | case strings.HasPrefix(trimmedPath, "api/"): | |
| 54 | + | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): | |
| 53 | 55 | actualPath = "/" + trimmedPath | |
| 54 | 56 | case path.Ext(trimmedPath) != "": | |
| 55 | 57 | actualPath = "/" + trimmedPath | |
| @@ -67,6 +69,36 @@ func toActualInternalURL(raw string) string { | |||
| 67 | 69 | return actual.String() | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 72 | + | func resolveAPIPath(u *url.URL) (string, bool) { | |
| 73 | + | if u == nil { | |
| 74 | + | return "", false | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | |
| 78 | + | if strings.HasPrefix(u.Path, internalAPIPathPrefix) { | |
| 79 | + | return u.Path, true | |
| 80 | + | } | |
| 81 | + | return "", false | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if !strings.EqualFold(u.Scheme, "dumb") { | |
| 85 | + | return "", false | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | if strings.EqualFold(u.Host, "api") { | |
| 89 | + | if u.Path == "" { | |
| 90 | + | return "/api", true | |
| 91 | + | } | |
| 92 | + | return internalAPIPathPrefix[:len(internalAPIPathPrefix)-1] + u.Path, true | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if strings.HasPrefix(u.Path, internalAPIPathPrefix) { | |
| 96 | + | return u.Path, true | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | return "", false | |
| 100 | + | } | |
| 101 | + | ||
| 70 | 102 | func toConceptualInternalURL(raw string) string { | |
| 71 | 103 | if raw == "" || !isActualInternalURL(raw) { | |
| 72 | 104 | return raw | |
M internal/infrastructure/cef/internal_urls_test.go +64 -1
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | func TestToActualInternalURL(t *testing.T) { | |
| 6 | 9 | t.Parallel() | |
| @@ -67,6 +70,66 @@ func TestToActualInternalURL(t *testing.T) { | |||
| 67 | 70 | } | |
| 68 | 71 | } | |
| 69 | 72 | ||
| 73 | + | func TestResolveAPIPath(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | ||
| 76 | + | tests := []struct { | |
| 77 | + | name string | |
| 78 | + | in string | |
| 79 | + | want string | |
| 80 | + | ok bool | |
| 81 | + | }{ | |
| 82 | + | { | |
| 83 | + | name: "nil url returns false", | |
| 84 | + | in: "", | |
| 85 | + | want: "", | |
| 86 | + | ok: false, | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "conceptual api host", | |
| 90 | + | in: "dumb://api/clipboard-set", | |
| 91 | + | want: "/api/clipboard-set", | |
| 92 | + | ok: true, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "conceptual page api path", | |
| 96 | + | in: "dumb://home/api/message", | |
| 97 | + | want: "/api/message", | |
| 98 | + | ok: true, | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | name: "actual internal api path", | |
| 102 | + | in: "https://dumber.invalid/api/focus-sync", | |
| 103 | + | want: "/api/focus-sync", | |
| 104 | + | ok: true, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "non api path", | |
| 108 | + | in: "https://dumber.invalid/home", | |
| 109 | + | want: "", | |
| 110 | + | ok: false, | |
| 111 | + | }, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | for _, tt := range tests { | |
| 115 | + | t.Run(tt.name, func(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 117 | + | var parsed *url.URL | |
| 118 | + | if tt.in != "" { | |
| 119 | + | var err error | |
| 120 | + | parsed, err = url.Parse(tt.in) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | got, ok := resolveAPIPath(parsed) | |
| 126 | + | if got != tt.want || ok != tt.ok { | |
| 127 | + | t.Fatalf("resolveAPIPath(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok) | |
| 128 | + | } | |
| 129 | + | }) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 70 | 133 | func TestToConceptualInternalURL(t *testing.T) { | |
| 71 | 134 | t.Parallel() | |
| 72 | 135 | ||
A internal/infrastructure/cef/popup_opener_bridge.go +454 -0
| @@ -0,0 +1,454 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | neturl "net/url" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type popupOpenerPostMessagePayload struct { | |
| 15 | + | Data string `json:"data"` | |
| 16 | + | DataKind string `json:"data_kind"` | |
| 17 | + | TargetOrigin string `json:"target_origin"` | |
| 18 | + | SourceOrigin string `json:"source_origin"` | |
| 19 | + | SourceHref string `json:"source_href"` | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type popupOpenerNavigatePayload struct { | |
| 23 | + | URL string `json:"url"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | const popupOpenerHTTPScheme = "http" | |
| 27 | + | ||
| 28 | + | func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) { | |
| 29 | + | if wv == nil { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | wv.mu.Lock() | |
| 33 | + | wv.popupNoJavaScriptAccess = noJavaScriptAccess | |
| 34 | + | wv.mu.Unlock() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // AddOpenerMessageCallback implements port.PopupOpenerCapable. | |
| 38 | + | func (wv *WebView) AddOpenerMessageCallback(fn func()) { | |
| 39 | + | if wv == nil || fn == nil { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | wv.mu.Lock() | |
| 43 | + | wv.openerMessageCallbacks = append(wv.openerMessageCallbacks, fn) | |
| 44 | + | wv.mu.Unlock() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // AddOpenerNavigationCallback implements port.PopupOpenerCapable. | |
| 48 | + | func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) { | |
| 49 | + | if wv == nil || fn == nil { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | wv.mu.Lock() | |
| 53 | + | wv.openerNavigationCallbacks = append(wv.openerNavigationCallbacks, fn) | |
| 54 | + | wv.mu.Unlock() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (wv *WebView) runOpenerMessageCallbacks() { | |
| 58 | + | if wv == nil { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | wv.mu.RLock() | |
| 62 | + | callbacks := append([]func(){}, wv.openerMessageCallbacks...) | |
| 63 | + | wv.mu.RUnlock() | |
| 64 | + | if len(callbacks) == 0 { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | wv.runOnGTK(func() { | |
| 68 | + | for _, fn := range callbacks { | |
| 69 | + | if fn != nil { | |
| 70 | + | fn() | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (wv *WebView) runOpenerNavigationCallbacks(uri string) { | |
| 77 | + | if wv == nil { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | wv.mu.RLock() | |
| 81 | + | callbacks := append([]func(string){}, wv.openerNavigationCallbacks...) | |
| 82 | + | wv.mu.RUnlock() | |
| 83 | + | if len(callbacks) == 0 { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | wv.runOnGTK(func() { | |
| 87 | + | for _, fn := range callbacks { | |
| 88 | + | if fn != nil { | |
| 89 | + | fn(uri) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // HasActivePopupOpenerBridge implements port.PopupOpenerCapable. | |
| 96 | + | func (wv *WebView) HasActivePopupOpenerBridge() bool { | |
| 97 | + | _, active, _ := wv.popupOpenerBridgeState() | |
| 98 | + | return active | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (wv *WebView) popupOpenerBridgeState() (parentURI string, active, blocked bool) { | |
| 102 | + | if wv == nil { | |
| 103 | + | return "", false, false | |
| 104 | + | } | |
| 105 | + | wv.mu.RLock() | |
| 106 | + | defer wv.mu.RUnlock() | |
| 107 | + | blocked = wv.popupNoJavaScriptAccess | |
| 108 | + | active = !blocked && wv.popupOpenerBridgeParent != nil | |
| 109 | + | parentURI = wv.popupOpenerBridgeParentURI | |
| 110 | + | return parentURI, active, blocked | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (wv *WebView) ensureBridgeNonceLocked() string { | |
| 114 | + | if wv == nil { | |
| 115 | + | return "" | |
| 116 | + | } | |
| 117 | + | if wv.bridgeNonce != "" { | |
| 118 | + | return wv.bridgeNonce | |
| 119 | + | } | |
| 120 | + | nonce := newBridgeNonce() | |
| 121 | + | if nonce == "" { | |
| 122 | + | return "" | |
| 123 | + | } | |
| 124 | + | wv.bridgeNonce = nonce | |
| 125 | + | return nonce | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (wv *WebView) syncPopupOpenerBridgeExtraInfoLocked() { | |
| 129 | + | if wv == nil || wv.pendingCreate == nil || wv.pendingCreate.windowInfo == nil { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if wv.popupNoJavaScriptAccess || wv.popupOpenerBridgeParent == nil || wv.popupOpenerBridgeParentURI == "" { | |
| 133 | + | wv.pendingCreate.extraInfo = nil | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | bridgeNonce := wv.ensureBridgeNonceLocked() | |
| 137 | + | if bridgeNonce == "" { | |
| 138 | + | wv.pendingCreate.extraInfo = nil | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | wv.pendingCreate.extraInfo = popupOpenerRenderExtraInfoBuilder(wv.popupOpenerBridgeParentURI, bridgeNonce) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // EnablePopupOpenerBridge implements port.PopupOpenerCapable. | |
| 145 | + | func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) { | |
| 146 | + | if wv == nil { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | wv.mu.Lock() | |
| 150 | + | defer wv.mu.Unlock() | |
| 151 | + | wv.popupNoJavaScriptAccess = noJavaScriptAccess | |
| 152 | + | if noJavaScriptAccess { | |
| 153 | + | wv.popupOpenerBridgeParent = nil | |
| 154 | + | wv.popupOpenerBridgeParentURI = "" | |
| 155 | + | wv.syncPopupOpenerBridgeExtraInfoLocked() | |
| 156 | + | if wv.ctx != nil { | |
| 157 | + | logging.FromContext(wv.ctx).Debug(). | |
| 158 | + | Uint64("webview_id", uint64(wv.id)). | |
| 159 | + | Bool("no_javascript_access", true). | |
| 160 | + | Msg("cef: popup opener bridge disabled") | |
| 161 | + | } | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | parentWV, ok := parent.(*WebView) | |
| 165 | + | if !ok || parentWV == nil { | |
| 166 | + | wv.popupOpenerBridgeParent = nil | |
| 167 | + | wv.popupOpenerBridgeParentURI = "" | |
| 168 | + | wv.syncPopupOpenerBridgeExtraInfoLocked() | |
| 169 | + | if wv.ctx != nil { | |
| 170 | + | logging.FromContext(wv.ctx).Debug(). | |
| 171 | + | Uint64("webview_id", uint64(wv.id)). | |
| 172 | + | Msg("cef: popup opener bridge unavailable: parent missing") | |
| 173 | + | } | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | wv.popupOpenerBridgeParent = parentWV | |
| 177 | + | wv.popupOpenerBridgeParentURI = parentWV.URI() | |
| 178 | + | wv.syncPopupOpenerBridgeExtraInfoLocked() | |
| 179 | + | if wv.ctx != nil { | |
| 180 | + | logging.FromContext(wv.ctx).Debug(). | |
| 181 | + | Uint64("webview_id", uint64(wv.id)). | |
| 182 | + | Uint64("parent_webview_id", uint64(parentWV.id)). | |
| 183 | + | Str("parent_uri", logging.TruncateURL(wv.popupOpenerBridgeParentURI, logging.PermissionLogURLMaxLen)). | |
| 184 | + | Msg("cef: popup opener bridge enabled") | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string { | |
| 189 | + | if wv == nil || bridgeNonce == "" { | |
| 190 | + | return "" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | wv.mu.RLock() | |
| 194 | + | parentURI := wv.popupOpenerBridgeParentURI | |
| 195 | + | parent := wv.popupOpenerBridgeParent | |
| 196 | + | noJavaScriptAccess := wv.popupNoJavaScriptAccess | |
| 197 | + | blocked := noJavaScriptAccess || parent == nil | |
| 198 | + | wv.mu.RUnlock() | |
| 199 | + | if blocked { | |
| 200 | + | if wv.ctx != nil { | |
| 201 | + | logging.FromContext(wv.ctx).Debug(). | |
| 202 | + | Uint64("webview_id", uint64(wv.id)). | |
| 203 | + | Bool("blocked", true). | |
| 204 | + | Bool("has_parent", parent != nil). | |
| 205 | + | Bool("no_javascript_access", noJavaScriptAccess). | |
| 206 | + | Msg("cef: popup opener bridge script skipped") | |
| 207 | + | } | |
| 208 | + | return "" | |
| 209 | + | } | |
| 210 | + | if wv.ctx != nil { | |
| 211 | + | logging.FromContext(wv.ctx).Debug(). | |
| 212 | + | Uint64("webview_id", uint64(wv.id)). | |
| 213 | + | Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)). | |
| 214 | + | Msg("cef: popup opener bridge script prepared") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | return buildPopupOpenerBridgeJS(bridgeNonce, parentURI) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (wv *WebView) handlePopupOpenerNavigate(targetURL string) { | |
| 221 | + | if wv == nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | trimmedURL := strings.TrimSpace(targetURL) | |
| 226 | + | if trimmedURL == "" { | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | wv.mu.RLock() | |
| 231 | + | opener := wv.popupOpenerBridgeParent | |
| 232 | + | wv.mu.RUnlock() | |
| 233 | + | if opener == nil || opener.destroyed.Load() { | |
| 234 | + | if wv.ctx != nil { | |
| 235 | + | logging.FromContext(wv.ctx).Debug(). | |
| 236 | + | Uint64("webview_id", uint64(wv.id)). | |
| 237 | + | Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)). | |
| 238 | + | Msg("cef: popup opener navigate ignored: opener unavailable") | |
| 239 | + | } | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | resolvedURL := resolvePopupOpenerNavigationTarget(trimmedURL, opener.URI()) | |
| 244 | + | if resolvedURL == "" { | |
| 245 | + | if wv.ctx != nil { | |
| 246 | + | logging.FromContext(wv.ctx).Warn(). | |
| 247 | + | Uint64("webview_id", uint64(wv.id)). | |
| 248 | + | Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)). | |
| 249 | + | Msg("cef: popup opener navigate rejected") | |
| 250 | + | } | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | if wv.ctx != nil { | |
| 254 | + | logging.FromContext(wv.ctx).Debug(). | |
| 255 | + | Uint64("webview_id", uint64(wv.id)). | |
| 256 | + | Str("target_url", logging.TruncateURL(trimmedURL, logging.PermissionLogURLMaxLen)). | |
| 257 | + | Str("resolved_url", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)). | |
| 258 | + | Msg("cef: popup opener navigate received") | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | if err := opener.LoadURI(context.Background(), resolvedURL); err != nil && opener.ctx != nil { | |
| 262 | + | logging.FromContext(opener.ctx).Warn(). | |
| 263 | + | Err(err). | |
| 264 | + | Str("uri", logging.TruncateURL(resolvedURL, logging.PermissionLogURLMaxLen)). | |
| 265 | + | Msg("cef: failed to navigate synthetic popup opener") | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | wv.mu.Lock() | |
| 269 | + | wv.popupOpenerBridgeParentURI = resolvedURL | |
| 270 | + | wv.mu.Unlock() | |
| 271 | + | wv.runOpenerNavigationCallbacks(resolvedURL) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func resolvePopupOpenerNavigationTarget(rawTarget, openerURI string) string { | |
| 275 | + | trimmedTarget := strings.TrimSpace(rawTarget) | |
| 276 | + | if trimmedTarget == "" { | |
| 277 | + | return "" | |
| 278 | + | } | |
| 279 | + | parsedTarget, err := neturl.Parse(trimmedTarget) | |
| 280 | + | if err == nil && parsedTarget.IsAbs() { | |
| 281 | + | return sanitizePopupOpenerNavigationTarget(parsedTarget) | |
| 282 | + | } | |
| 283 | + | if strings.HasPrefix(trimmedTarget, "//") { | |
| 284 | + | base, baseErr := neturl.Parse(strings.TrimSpace(openerURI)) | |
| 285 | + | if baseErr == nil && base != nil && base.Scheme != "" { | |
| 286 | + | parsedTarget.Scheme = base.Scheme | |
| 287 | + | return sanitizePopupOpenerNavigationTarget(parsedTarget) | |
| 288 | + | } | |
| 289 | + | return "" | |
| 290 | + | } | |
| 291 | + | base, err := neturl.Parse(strings.TrimSpace(openerURI)) | |
| 292 | + | if err != nil || base == nil || base.Scheme == "" || base.Host == "" { | |
| 293 | + | return "" | |
| 294 | + | } | |
| 295 | + | ref, err := neturl.Parse(trimmedTarget) | |
| 296 | + | if err != nil { | |
| 297 | + | return "" | |
| 298 | + | } | |
| 299 | + | return sanitizePopupOpenerNavigationTarget(base.ResolveReference(ref)) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func sanitizePopupOpenerNavigationTarget(target *neturl.URL) string { | |
| 303 | + | if target == nil { | |
| 304 | + | return "" | |
| 305 | + | } | |
| 306 | + | switch scheme := strings.ToLower(strings.TrimSpace(target.Scheme)); scheme { | |
| 307 | + | case popupOpenerHTTPScheme, actualInternalScheme: | |
| 308 | + | return target.String() | |
| 309 | + | default: | |
| 310 | + | return "" | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (wv *WebView) handlePopupOpenerPostMessage(payload popupOpenerPostMessagePayload) { | |
| 315 | + | if wv == nil { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | wv.mu.RLock() | |
| 320 | + | opener := wv.popupOpenerBridgeParent | |
| 321 | + | wv.mu.RUnlock() | |
| 322 | + | if opener == nil || opener.destroyed.Load() { | |
| 323 | + | if wv.ctx != nil { | |
| 324 | + | logging.FromContext(wv.ctx).Debug(). | |
| 325 | + | Uint64("webview_id", uint64(wv.id)). | |
| 326 | + | Str("target_origin", payload.TargetOrigin). | |
| 327 | + | Msg("cef: popup opener postMessage ignored: opener unavailable") | |
| 328 | + | } | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | if !targetOriginMatchesPopupOpener(payload.TargetOrigin, opener.URI()) { | |
| 332 | + | if wv.ctx != nil { | |
| 333 | + | logging.FromContext(wv.ctx).Warn(). | |
| 334 | + | Uint64("webview_id", uint64(wv.id)). | |
| 335 | + | Str("target_origin", payload.TargetOrigin). | |
| 336 | + | Str("opener_uri", logging.TruncateURL(opener.URI(), logging.PermissionLogURLMaxLen)). | |
| 337 | + | Msg("cef: popup opener postMessage rejected") | |
| 338 | + | } | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | sourceOrigin, sourceHref := popupOpenerSourceMetadata(wv) | |
| 342 | + | if wv.ctx != nil { | |
| 343 | + | logging.FromContext(wv.ctx).Debug(). | |
| 344 | + | Uint64("webview_id", uint64(wv.id)). | |
| 345 | + | Str("target_origin", payload.TargetOrigin). | |
| 346 | + | Str("source_origin", sourceOrigin). | |
| 347 | + | Str("source_href", logging.TruncateURL(sourceHref, logging.PermissionLogURLMaxLen)). | |
| 348 | + | Msg("cef: popup opener postMessage received") | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | var dataExpr string | |
| 352 | + | switch strings.ToLower(strings.TrimSpace(payload.DataKind)) { | |
| 353 | + | case "json": | |
| 354 | + | dataExpr = fmt.Sprintf("JSON.parse('%s')", webutil.EscapeForJSString(payload.Data)) | |
| 355 | + | case "string": | |
| 356 | + | dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data)) | |
| 357 | + | default: | |
| 358 | + | dataExpr = fmt.Sprintf("'%s'", webutil.EscapeForJSString(payload.Data)) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | script := fmt.Sprintf(`(function() { | |
| 362 | + | try { | |
| 363 | + | var data; | |
| 364 | + | try { | |
| 365 | + | data = %s; | |
| 366 | + | } catch (_) { | |
| 367 | + | data = undefined; | |
| 368 | + | } | |
| 369 | + | var event = new MessageEvent('message', { | |
| 370 | + | data: data, | |
| 371 | + | origin: '%s', | |
| 372 | + | source: null | |
| 373 | + | }); | |
| 374 | + | var sourceProxy = { | |
| 375 | + | closed: false, | |
| 376 | + | close: function() { return undefined; }, | |
| 377 | + | focus: function() { return undefined; }, | |
| 378 | + | postMessage: function() { return undefined; }, | |
| 379 | + | location: { | |
| 380 | + | href: '%s', | |
| 381 | + | toString: function() { return this.href; } | |
| 382 | + | } | |
| 383 | + | }; | |
| 384 | + | try { | |
| 385 | + | Object.defineProperty(event, 'source', { | |
| 386 | + | configurable: true, | |
| 387 | + | enumerable: true, | |
| 388 | + | value: sourceProxy | |
| 389 | + | }); | |
| 390 | + | } catch (_) {} | |
| 391 | + | window.dispatchEvent(event); | |
| 392 | + | } catch (_) {} | |
| 393 | + | })();`, dataExpr, webutil.EscapeForJSString(sourceOrigin), webutil.EscapeForJSString(sourceHref)) | |
| 394 | + | opener.RunJavaScript(context.Background(), script) | |
| 395 | + | wv.runOpenerMessageCallbacks() | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func targetOriginMatchesPopupOpener(targetOrigin, openerURI string) bool { | |
| 399 | + | trimmedTarget := strings.TrimSpace(targetOrigin) | |
| 400 | + | if trimmedTarget == "" || trimmedTarget == "*" { | |
| 401 | + | return true | |
| 402 | + | } | |
| 403 | + | targetCanonical := canonicalOrigin(trimmedTarget) | |
| 404 | + | return targetCanonical != "" && targetCanonical == canonicalOrigin(openerURI) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func popupOpenerSourceMetadata(wv *WebView) (origin, href string) { | |
| 408 | + | if wv == nil { | |
| 409 | + | return "", "" | |
| 410 | + | } | |
| 411 | + | href = strings.TrimSpace(wv.URI()) | |
| 412 | + | if href == "" { | |
| 413 | + | href = strings.TrimSpace(wv.pendingNavigationURI()) | |
| 414 | + | } | |
| 415 | + | if href == "" { | |
| 416 | + | return "", "" | |
| 417 | + | } | |
| 418 | + | return originFromURL(href), href | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func originFromURL(rawURL string) string { | |
| 422 | + | canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL)) | |
| 423 | + | parsed, err := neturl.Parse(canonicalURL) | |
| 424 | + | if err != nil || parsed.Scheme == "" || parsed.Host == "" { | |
| 425 | + | return "" | |
| 426 | + | } | |
| 427 | + | return parsed.Scheme + "://" + parsed.Host | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func canonicalOrigin(rawURL string) string { | |
| 431 | + | canonicalURL := toActualInternalURL(strings.TrimSpace(rawURL)) | |
| 432 | + | parsed, err := neturl.Parse(canonicalURL) | |
| 433 | + | if err != nil || parsed.Scheme == "" || parsed.Host == "" { | |
| 434 | + | return "" | |
| 435 | + | } | |
| 436 | + | scheme := strings.ToLower(parsed.Scheme) | |
| 437 | + | host := strings.ToLower(parsed.Hostname()) | |
| 438 | + | if host == "" { | |
| 439 | + | return "" | |
| 440 | + | } | |
| 441 | + | portStr := parsed.Port() | |
| 442 | + | if portStr == "" { | |
| 443 | + | switch scheme { | |
| 444 | + | case popupOpenerHTTPScheme: | |
| 445 | + | portStr = "80" | |
| 446 | + | case actualInternalScheme: | |
| 447 | + | portStr = "443" | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | if portStr == "" { | |
| 451 | + | return scheme + "://" + host | |
| 452 | + | } | |
| 453 | + | return scheme + "://" + host + ":" + portStr | |
| 454 | + | } | |
A internal/infrastructure/cef/popup_opener_bridge_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestResolvePopupOpenerNavigationTarget_ResolvesRelativeAgainstOpener(t *testing.T) { | |
| 14 | + | require.Equal( | |
| 15 | + | t, | |
| 16 | + | "https://example.com/oauth/callback", | |
| 17 | + | resolvePopupOpenerNavigationTarget("callback", "https://example.com/oauth/start"), | |
| 18 | + | ) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestResolvePopupOpenerNavigationTarget_PreservesAbsoluteTarget(t *testing.T) { | |
| 22 | + | require.Equal( | |
| 23 | + | t, | |
| 24 | + | "https://accounts.example.com/finish", | |
| 25 | + | resolvePopupOpenerNavigationTarget("https://accounts.example.com/finish", "https://example.com/oauth/start"), | |
| 26 | + | ) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestResolvePopupOpenerNavigationTarget_RejectsNonHTTPAbsoluteTarget(t *testing.T) { | |
| 30 | + | require.Empty(t, resolvePopupOpenerNavigationTarget("javascript:alert(1)", "https://example.com/oauth/start")) | |
| 31 | + | require.Empty(t, resolvePopupOpenerNavigationTarget("data:text/html,boom", "https://example.com/oauth/start")) | |
| 32 | + | require.Empty(t, resolvePopupOpenerNavigationTarget("file:///tmp/boom", "https://example.com/oauth/start")) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) { | |
| 36 | + | require.Equal(t, "https://example.com:8443", originFromURL("https://example.com:8443/callback")) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | |
| 40 | + | require.Equal(t, actualInternalOrigin, originFromURL("dumb://home")) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | |
| 44 | + | require.True(t, targetOriginMatchesPopupOpener("https://example.com:443", "https://example.com/callback")) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestHandlePopupOpenerPostMessage_UsesPopupCommittedURIForSourceMetadata(t *testing.T) { | |
| 48 | + | frame := cefmocks.NewMockFrame(t) | |
| 49 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |
| 50 | + | return strings.Contains(script, "https://popup.example") && | |
| 51 | + | strings.Contains(script, "https://popup.example/oauth/callback") && | |
| 52 | + | !strings.Contains(script, "https://evil.example") | |
| 53 | + | }), "", int32(0)).Once() | |
| 54 | + | browser := cefmocks.NewMockBrowser(t) | |
| 55 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 56 | + | ||
| 57 | + | opener := &WebView{ctx: context.Background(), browser: browser, uri: "https://app.example/session"} | |
| 58 | + | popup := &WebView{ | |
| 59 | + | ctx: context.Background(), | |
| 60 | + | uri: "https://popup.example/oauth/callback", | |
| 61 | + | popupOpenerBridgeParent: opener, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | popup.handlePopupOpenerPostMessage(popupOpenerPostMessagePayload{ | |
| 65 | + | Data: `{"ok":true}`, | |
| 66 | + | DataKind: "json", | |
| 67 | + | TargetOrigin: "https://app.example", | |
| 68 | + | SourceOrigin: "https://evil.example", | |
| 69 | + | SourceHref: "https://evil.example/fake", | |
| 70 | + | }) | |
| 71 | + | } | |
A internal/infrastructure/cef/popup_opener_renderer.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | popupOpenerExtraInfoEnabledKey = "dumber.popup_opener.enabled" | |
| 14 | + | popupOpenerExtraInfoParentURIKey = "dumber.popup_opener.parent_uri" | |
| 15 | + | popupOpenerExtraInfoBridgeNonceKey = "dumber.popup_opener.bridge_nonce" | |
| 16 | + | popupOpenerRenderScriptURL = "dumber://popup-opener-renderer" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type popupOpenerRenderMetadata struct { | |
| 20 | + | ParentURI string | |
| 21 | + | BridgeNonce string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | var popupOpenerRenderExtraInfoBuilder = buildPopupOpenerRenderExtraInfo | |
| 25 | + | ||
| 26 | + | func buildPopupOpenerRenderExtraInfo(parentURI, bridgeNonce string) purecef.DictionaryValue { | |
| 27 | + | if parentURI == "" || bridgeNonce == "" { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | extraInfo := purecef.DictionaryValueCreate() | |
| 31 | + | if extraInfo == nil { | |
| 32 | + | return nil | |
| 33 | + | } | |
| 34 | + | extraInfo.SetBool(popupOpenerExtraInfoEnabledKey, 1) | |
| 35 | + | extraInfo.SetString(popupOpenerExtraInfoParentURIKey, parentURI) | |
| 36 | + | extraInfo.SetString(popupOpenerExtraInfoBridgeNonceKey, bridgeNonce) | |
| 37 | + | return extraInfo | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func decodePopupOpenerRenderMetadata(extraInfo purecef.DictionaryValue) (popupOpenerRenderMetadata, bool) { | |
| 41 | + | var metadata popupOpenerRenderMetadata | |
| 42 | + | if extraInfo == nil || extraInfo.GetBool(popupOpenerExtraInfoEnabledKey) != 1 { | |
| 43 | + | return metadata, false | |
| 44 | + | } | |
| 45 | + | metadata.ParentURI = extraInfo.GetString(popupOpenerExtraInfoParentURIKey) | |
| 46 | + | metadata.BridgeNonce = extraInfo.GetString(popupOpenerExtraInfoBridgeNonceKey) | |
| 47 | + | if metadata.ParentURI == "" || metadata.BridgeNonce == "" { | |
| 48 | + | return popupOpenerRenderMetadata{}, false | |
| 49 | + | } | |
| 50 | + | return metadata, true | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | type popupOpenerRenderProcessHandler struct { | |
| 54 | + | mu sync.RWMutex | |
| 55 | + | byBrowserID map[int32]popupOpenerRenderMetadata | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func newPopupOpenerRenderProcessHandler() *popupOpenerRenderProcessHandler { | |
| 59 | + | return &popupOpenerRenderProcessHandler{byBrowserID: make(map[int32]popupOpenerRenderMetadata)} | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (*popupOpenerRenderProcessHandler) OnWebKitInitialized() {} | |
| 63 | + | ||
| 64 | + | func (h *popupOpenerRenderProcessHandler) OnBrowserCreated(browser purecef.Browser, extraInfo purecef.DictionaryValue) { | |
| 65 | + | if h == nil || browser == nil { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | metadata, ok := decodePopupOpenerRenderMetadata(extraInfo) | |
| 69 | + | if !ok { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | browserID := browser.GetIdentifier() | |
| 73 | + | h.mu.Lock() | |
| 74 | + | h.byBrowserID[browserID] = metadata | |
| 75 | + | h.mu.Unlock() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (h *popupOpenerRenderProcessHandler) OnBrowserDestroyed(browser purecef.Browser) { | |
| 79 | + | if h == nil || browser == nil { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | browserID := browser.GetIdentifier() | |
| 83 | + | h.mu.Lock() | |
| 84 | + | delete(h.byBrowserID, browserID) | |
| 85 | + | h.mu.Unlock() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (*popupOpenerRenderProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil } | |
| 89 | + | ||
| 90 | + | func (h *popupOpenerRenderProcessHandler) OnContextCreated(browser purecef.Browser, frame purecef.Frame, ctx purecef.V8Context) { | |
| 91 | + | if h == nil || browser == nil || frame == nil || !frame.IsMain() || ctx == nil || !ctx.IsValid() { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | browserID := browser.GetIdentifier() | |
| 95 | + | h.mu.RLock() | |
| 96 | + | metadata, ok := h.byBrowserID[browserID] | |
| 97 | + | h.mu.RUnlock() | |
| 98 | + | if !ok { | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | script := buildPopupOpenerBridgeJS(metadata.BridgeNonce, metadata.ParentURI) | |
| 103 | + | if script == "" { | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | if ctx.Enter() != 1 { | |
| 107 | + | logging.FromContext(context.Background()).Warn(). | |
| 108 | + | Int32("browser_id", browserID). | |
| 109 | + | Msg("cef: popup opener renderer bridge could not enter V8 context") | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | defer ctx.Exit() | |
| 113 | + | if ctx.Eval(script, popupOpenerRenderScriptURL, 0, nil, nil) != 1 { | |
| 114 | + | logging.FromContext(context.Background()).Warn(). | |
| 115 | + | Int32("browser_id", browserID). | |
| 116 | + | Msg("cef: popup opener renderer bridge eval failed") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (*popupOpenerRenderProcessHandler) OnContextReleased(purecef.Browser, purecef.Frame, purecef.V8Context) { | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (*popupOpenerRenderProcessHandler) OnUncaughtException( | |
| 124 | + | purecef.Browser, | |
| 125 | + | purecef.Frame, | |
| 126 | + | purecef.V8Context, | |
| 127 | + | purecef.V8Exception, | |
| 128 | + | purecef.V8StackTrace, | |
| 129 | + | ) { | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (*popupOpenerRenderProcessHandler) OnFocusedNodeChanged(purecef.Browser, purecef.Frame, purecef.Domnode) { | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (*popupOpenerRenderProcessHandler) OnProcessMessageReceived( | |
| 136 | + | purecef.Browser, | |
| 137 | + | purecef.Frame, | |
| 138 | + | purecef.ProcessID, | |
| 139 | + | purecef.ProcessMessage, | |
| 140 | + | ) int32 { | |
| 141 | + | return 0 | |
| 142 | + | } | |
A internal/infrastructure/cef/popup_opener_renderer_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "unsafe" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type testPopupOpenerDictionaryValue struct { | |
| 13 | + | strings map[string]string | |
| 14 | + | bools map[string]int32 | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (v testPopupOpenerDictionaryValue) IsValid() bool { return true } | |
| 18 | + | func (v testPopupOpenerDictionaryValue) IsOwned() bool { return true } | |
| 19 | + | func (v testPopupOpenerDictionaryValue) IsReadOnly() bool { return false } | |
| 20 | + | func (v testPopupOpenerDictionaryValue) IsSame(purecef.DictionaryValue) bool { return false } | |
| 21 | + | func (v testPopupOpenerDictionaryValue) IsEqual(purecef.DictionaryValue) bool { return false } | |
| 22 | + | func (v testPopupOpenerDictionaryValue) Copy(int32) purecef.DictionaryValue { return v } | |
| 23 | + | func (v testPopupOpenerDictionaryValue) GetSize() int { return len(v.strings) + len(v.bools) } | |
| 24 | + | func (v testPopupOpenerDictionaryValue) Clear() int32 { return 1 } | |
| 25 | + | func (v testPopupOpenerDictionaryValue) HasKey(key string) bool { | |
| 26 | + | _, ok := v.strings[key] | |
| 27 | + | if ok { | |
| 28 | + | return true | |
| 29 | + | } | |
| 30 | + | _, ok = v.bools[key] | |
| 31 | + | return ok | |
| 32 | + | } | |
| 33 | + | func (v testPopupOpenerDictionaryValue) GetKeys(purecef.StringList) int32 { return 0 } | |
| 34 | + | func (v testPopupOpenerDictionaryValue) Remove(string) int32 { return 1 } | |
| 35 | + | func (v testPopupOpenerDictionaryValue) GetType(string) purecef.ValueType { return 0 } | |
| 36 | + | func (v testPopupOpenerDictionaryValue) GetValue(string) purecef.Value { return nil } | |
| 37 | + | func (v testPopupOpenerDictionaryValue) GetBool(key string) int32 { return v.bools[key] } | |
| 38 | + | func (v testPopupOpenerDictionaryValue) GetInt(string) int32 { return 0 } | |
| 39 | + | func (v testPopupOpenerDictionaryValue) GetDouble(string) float64 { return 0 } | |
| 40 | + | func (v testPopupOpenerDictionaryValue) GetString(key string) string { return v.strings[key] } | |
| 41 | + | func (v testPopupOpenerDictionaryValue) GetBinary(string) purecef.BinaryValue { return nil } | |
| 42 | + | func (v testPopupOpenerDictionaryValue) GetDictionary(string) purecef.DictionaryValue { return nil } | |
| 43 | + | func (v testPopupOpenerDictionaryValue) GetList(string) purecef.ListValue { return nil } | |
| 44 | + | func (v testPopupOpenerDictionaryValue) SetValue(string, purecef.Value) int32 { return 1 } | |
| 45 | + | func (v testPopupOpenerDictionaryValue) SetNull(string) int32 { return 1 } | |
| 46 | + | func (v testPopupOpenerDictionaryValue) SetBool(string, int32) int32 { return 1 } | |
| 47 | + | func (v testPopupOpenerDictionaryValue) SetInt(string, int32) int32 { return 1 } | |
| 48 | + | func (v testPopupOpenerDictionaryValue) SetDouble(string, float64) int32 { return 1 } | |
| 49 | + | func (v testPopupOpenerDictionaryValue) SetString(string, string) int32 { return 1 } | |
| 50 | + | func (v testPopupOpenerDictionaryValue) SetBinary(string, purecef.BinaryValue) int32 { return 1 } | |
| 51 | + | func (v testPopupOpenerDictionaryValue) SetDictionary(string, purecef.DictionaryValue) int32 { | |
| 52 | + | return 1 | |
| 53 | + | } | |
| 54 | + | func (v testPopupOpenerDictionaryValue) SetList(string, purecef.ListValue) int32 { return 1 } | |
| 55 | + | ||
| 56 | + | type testPopupOpenerV8Context struct { | |
| 57 | + | valid bool | |
| 58 | + | evalCode string | |
| 59 | + | evalURL string | |
| 60 | + | enterCalls int | |
| 61 | + | exitCalls int | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (c *testPopupOpenerV8Context) GetTaskRunner() purecef.TaskRunner { return nil } | |
| 65 | + | func (c *testPopupOpenerV8Context) IsValid() bool { return c.valid } | |
| 66 | + | func (c *testPopupOpenerV8Context) GetBrowser() purecef.Browser { return nil } | |
| 67 | + | func (c *testPopupOpenerV8Context) GetFrame() purecef.Frame { return nil } | |
| 68 | + | func (c *testPopupOpenerV8Context) GetGlobal() purecef.V8Value { return nil } | |
| 69 | + | func (c *testPopupOpenerV8Context) Enter() int32 { | |
| 70 | + | c.enterCalls++ | |
| 71 | + | return 1 | |
| 72 | + | } | |
| 73 | + | func (c *testPopupOpenerV8Context) Exit() int32 { | |
| 74 | + | c.exitCalls++ | |
| 75 | + | return 1 | |
| 76 | + | } | |
| 77 | + | func (c *testPopupOpenerV8Context) IsSame(that purecef.V8Context) bool { return c == that } | |
| 78 | + | func (c *testPopupOpenerV8Context) Eval(code, scriptURL string, _ int32, _, _ unsafe.Pointer) int32 { | |
| 79 | + | c.evalCode = code | |
| 80 | + | c.evalURL = scriptURL | |
| 81 | + | return 1 | |
| 82 | + | } | |
| 83 | + | func (c *testPopupOpenerV8Context) RawPointer() unsafe.Pointer { return nil } | |
| 84 | + | func (c *testPopupOpenerV8Context) Release() {} | |
| 85 | + | ||
| 86 | + | func TestDecodePopupOpenerRenderMetadata(t *testing.T) { | |
| 87 | + | metadata, ok := decodePopupOpenerRenderMetadata(testPopupOpenerDictionaryValue{ | |
| 88 | + | strings: map[string]string{ | |
| 89 | + | popupOpenerExtraInfoParentURIKey: "https://example.com/login", | |
| 90 | + | popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce", | |
| 91 | + | }, | |
| 92 | + | bools: map[string]int32{ | |
| 93 | + | popupOpenerExtraInfoEnabledKey: 1, | |
| 94 | + | }, | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | require.True(t, ok) | |
| 98 | + | require.Equal(t, "https://example.com/login", metadata.ParentURI) | |
| 99 | + | require.Equal(t, "bridge-nonce", metadata.BridgeNonce) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestPopupOpenerRenderProcessHandler_OnContextCreatedInstallsBridgeForConfiguredMainFrame(t *testing.T) { | |
| 103 | + | h := newPopupOpenerRenderProcessHandler() | |
| 104 | + | browser := cefmocks.NewMockBrowser(t) | |
| 105 | + | browser.EXPECT().GetIdentifier().Return(int32(42)).Twice() | |
| 106 | + | ctx := &testPopupOpenerV8Context{valid: true} | |
| 107 | + | ||
| 108 | + | h.OnBrowserCreated(browser, testPopupOpenerDictionaryValue{ | |
| 109 | + | strings: map[string]string{ | |
| 110 | + | popupOpenerExtraInfoParentURIKey: "https://example.com/login", | |
| 111 | + | popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce", | |
| 112 | + | }, | |
| 113 | + | bools: map[string]int32{ | |
| 114 | + | popupOpenerExtraInfoEnabledKey: 1, | |
| 115 | + | }, | |
| 116 | + | }) | |
| 117 | + | h.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx) | |
| 118 | + | ||
| 119 | + | require.Equal(t, 1, ctx.enterCalls) | |
| 120 | + | require.Equal(t, 1, ctx.exitCalls) | |
| 121 | + | require.Contains(t, ctx.evalCode, "popup-opener-navigate") | |
| 122 | + | require.Contains(t, ctx.evalCode, "popup-opener-post-message") | |
| 123 | + | require.Contains(t, ctx.evalCode, "https://example.com/login") | |
| 124 | + | require.Contains(t, ctx.evalCode, "bridge-nonce") | |
| 125 | + | require.Equal(t, popupOpenerRenderScriptURL, ctx.evalURL) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestPopupOpenerRenderProcessHandler_OnContextCreatedSkipsWithoutMetadata(t *testing.T) { | |
| 129 | + | h := newPopupOpenerRenderProcessHandler() | |
| 130 | + | browser := cefmocks.NewMockBrowser(t) | |
| 131 | + | browser.EXPECT().GetIdentifier().Return(int32(7)).Once() | |
| 132 | + | ctx := &testPopupOpenerV8Context{valid: true} | |
| 133 | + | ||
| 134 | + | h.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx) | |
| 135 | + | ||
| 136 | + | require.Zero(t, ctx.enterCalls) | |
| 137 | + | require.Empty(t, ctx.evalCode) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestEnablePopupOpenerBridge_SyncsPendingCreateExtraInfo(t *testing.T) { | |
| 141 | + | oldBuilder := popupOpenerRenderExtraInfoBuilder | |
| 142 | + | popupOpenerRenderExtraInfoBuilder = func(parentURI, bridgeNonce string) purecef.DictionaryValue { | |
| 143 | + | require.Equal(t, "https://example.com/login", parentURI) | |
| 144 | + | require.NotEmpty(t, bridgeNonce) | |
| 145 | + | return testPopupOpenerDictionaryValue{strings: map[string]string{"ok": "1"}} | |
| 146 | + | } | |
| 147 | + | t.Cleanup(func() { | |
| 148 | + | popupOpenerRenderExtraInfoBuilder = oldBuilder | |
| 149 | + | }) | |
| 150 | + | ||
| 151 | + | parent := &WebView{} | |
| 152 | + | parent.updateURI("https://example.com/login") | |
| 153 | + | windowInfo := purecef.NewWindowInfo() | |
| 154 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{windowInfo: &windowInfo}} | |
| 155 | + | ||
| 156 | + | wv.EnablePopupOpenerBridge(parent, false) | |
| 157 | + | ||
| 158 | + | require.NotNil(t, wv.pendingCreate.extraInfo) | |
| 159 | + | require.NotEmpty(t, wv.bridgeNonce) | |
| 160 | + | } | |
M internal/infrastructure/cef/render_pipeline.go +162 -10
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -25,9 +26,10 @@ type rect struct { | |||
| 25 | 26 | // and GTK later uploads from staging → PBO → texture before drawing a | |
| 26 | 27 | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | |
| 27 | 28 | type renderPipeline struct { | |
| 28 | - | ctx context.Context | |
| 29 | - | gl *glLoader | |
| 30 | - | glArea *gtk.GLArea | |
| 29 | + | webviewID port.WebViewID | |
| 30 | + | ctx context.Context | |
| 31 | + | gl *glLoader | |
| 32 | + | glArea *gtk.GLArea | |
| 31 | 33 | ||
| 32 | 34 | // GL resources (created on first render). | |
| 33 | 35 | texture uint32 | |
| @@ -71,7 +73,11 @@ type renderPipeline struct { | |||
| 71 | 73 | viewRectSeq atomic.Uint64 | |
| 72 | 74 | screenInfoSeq atomic.Uint64 | |
| 73 | 75 | paintSeq atomic.Uint64 | |
| 76 | + | resizeSeq atomic.Uint64 | |
| 74 | 77 | lastQueuedPaintSeq atomic.Uint64 | |
| 78 | + | lastResizeUnixNano atomic.Int64 | |
| 79 | + | lastResizePaintLogSeq atomic.Uint64 | |
| 80 | + | lastResizeStaleLogSeq atomic.Uint64 | |
| 75 | 81 | glRenderSeq atomic.Uint64 | |
| 76 | 82 | paintCount uint64 | |
| 77 | 83 | acceleratedPaintCount uint64 | |
| @@ -108,6 +114,27 @@ type renderPipeline struct { | |||
| 108 | 114 | onResizeCB func(width, height int32) | |
| 109 | 115 | } | |
| 110 | 116 | ||
| 117 | + | type renderPipelineSnapshot struct { | |
| 118 | + | Width int32 | |
| 119 | + | Height int32 | |
| 120 | + | Scale int32 | |
| 121 | + | GLReady bool | |
| 122 | + | NeedsUpload bool | |
| 123 | + | SizeChanged bool | |
| 124 | + | ForceFullUpload bool | |
| 125 | + | ViewRectSeq uint64 | |
| 126 | + | ScreenInfoSeq uint64 | |
| 127 | + | PaintSeq uint64 | |
| 128 | + | ResizeSeq uint64 | |
| 129 | + | LastQueuedPaintSeq uint64 | |
| 130 | + | GLRenderSeq uint64 | |
| 131 | + | PaintCount uint64 | |
| 132 | + | QueueRenderCount uint64 | |
| 133 | + | RenderCount uint64 | |
| 134 | + | UploadCount uint64 | |
| 135 | + | FullUploadCount uint64 | |
| 136 | + | } | |
| 137 | + | ||
| 111 | 138 | // Shader sources for the fullscreen textured quad. | |
| 112 | 139 | const vertexShaderSource = "" + | |
| 113 | 140 | "#version 330 core\n" + | |
| @@ -139,15 +166,16 @@ var quadVertices = [16]float32{ | |||
| 139 | 166 | ||
| 140 | 167 | // newRenderPipeline creates a GtkGLArea and wires up the render and resize | |
| 141 | 168 | // signals. The returned pipeline is ready to receive handlePaint calls. | |
| 142 | - | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPipeline { | |
| 169 | + | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID port.WebViewID) *renderPipeline { | |
| 143 | 170 | if scale < 1 { | |
| 144 | 171 | scale = 1 | |
| 145 | 172 | } | |
| 146 | 173 | ||
| 147 | 174 | rp := &renderPipeline{ | |
| 148 | - | ctx: ctx, | |
| 149 | - | gl: gl, | |
| 150 | - | scale: scale, | |
| 175 | + | webviewID: webviewID, | |
| 176 | + | ctx: ctx, | |
| 177 | + | gl: gl, | |
| 178 | + | scale: scale, | |
| 151 | 179 | } | |
| 152 | 180 | ||
| 153 | 181 | rp.glArea = gtk.NewGLArea() | |
| @@ -156,8 +184,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi | |||
| 156 | 184 | rp.glArea.SetHasDepthBuffer(false) | |
| 157 | 185 | rp.glArea.SetHasStencilBuffer(false) | |
| 158 | 186 | ||
| 159 | - | // Wire signals. puregotk takes *func(...) for signal callbacks. | |
| 160 | - | renderCb := func(_ gtk.GLArea, _ *gdk.GLContext) bool { | |
| 187 | + | // Wire signals. puregotk currently exposes the render context as a raw pointer. | |
| 188 | + | renderCb := func(_ gtk.GLArea, contextPtr uintptr) bool { | |
| 189 | + | _ = gdk.GLContextNewFromInternalPtr(contextPtr) | |
| 161 | 190 | return rp.onGLRender() | |
| 162 | 191 | } | |
| 163 | 192 | rp.glArea.ConnectRender(&renderCb) | |
| @@ -292,6 +321,21 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 292 | 321 | ||
| 293 | 322 | rp.mu.Unlock() | |
| 294 | 323 | ||
| 324 | + | if rp.ctx != nil && sizeChanged && !needsUpload { | |
| 325 | + | resizeSeq, resizeAgeMs := rp.latestResizeDiagnostics() | |
| 326 | + | logging.FromContext(rp.ctx).Warn(). | |
| 327 | + | Uint64("webview_id", uint64(rp.webviewID)). | |
| 328 | + | Uint64("resize_seq", resizeSeq). | |
| 329 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 330 | + | Int32("upload_width", uploadWidth). | |
| 331 | + | Int32("upload_height", uploadHeight). | |
| 332 | + | Bool("gl_ready", rp.glReady). | |
| 333 | + | Bool("needs_upload", needsUpload). | |
| 334 | + | Bool("size_changed", sizeChanged). | |
| 335 | + | Uint64("last_queued_paint_seq", paintSeq). | |
| 336 | + | Msg("cef: render after resize without fresh paint") | |
| 337 | + | } | |
| 338 | + | ||
| 295 | 339 | if !rp.glReady || sizeChanged { | |
| 296 | 340 | rp.initGL(uploadWidth, uploadHeight) | |
| 297 | 341 | } | |
| @@ -783,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() { | |||
| 783 | 827 | } | |
| 784 | 828 | ||
| 785 | 829 | // buildShaderProgram compiles and links the vertex+fragment shaders. | |
| 830 | + | func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot { | |
| 831 | + | if rp == nil { | |
| 832 | + | return renderPipelineSnapshot{} | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | rp.diagMu.Lock() | |
| 836 | + | snap := renderPipelineSnapshot{ | |
| 837 | + | Width: rp.widthAtomic.Load(), | |
| 838 | + | Height: rp.heightAtomic.Load(), | |
| 839 | + | ViewRectSeq: rp.viewRectSeq.Load(), | |
| 840 | + | ScreenInfoSeq: rp.screenInfoSeq.Load(), | |
| 841 | + | PaintSeq: rp.paintSeq.Load(), | |
| 842 | + | ResizeSeq: rp.resizeSeq.Load(), | |
| 843 | + | LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(), | |
| 844 | + | GLRenderSeq: rp.glRenderSeq.Load(), | |
| 845 | + | PaintCount: rp.paintCount, | |
| 846 | + | QueueRenderCount: rp.queueRenderCount, | |
| 847 | + | RenderCount: rp.glRenderCount, | |
| 848 | + | UploadCount: rp.uploadCount, | |
| 849 | + | FullUploadCount: rp.fullUploadCount, | |
| 850 | + | } | |
| 851 | + | rp.diagMu.Unlock() | |
| 852 | + | ||
| 853 | + | rp.mu.Lock() | |
| 854 | + | snap.Scale = rp.scale | |
| 855 | + | snap.GLReady = rp.glReady | |
| 856 | + | snap.NeedsUpload = rp.needsUpload | |
| 857 | + | snap.SizeChanged = rp.sizeChanged | |
| 858 | + | snap.ForceFullUpload = rp.forceFullUpload | |
| 859 | + | rp.mu.Unlock() | |
| 860 | + | ||
| 861 | + | return snap | |
| 862 | + | } | |
| 863 | + | ||
| 786 | 864 | func (rp *renderPipeline) buildShaderProgram() uint32 { | |
| 787 | 865 | gl := rp.gl | |
| 788 | 866 | ||
| @@ -819,13 +897,27 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||
| 819 | 897 | // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels; | |
| 820 | 898 | // we multiply by scale to get device pixels. | |
| 821 | 899 | func (rp *renderPipeline) onResize(width, height int32) { | |
| 900 | + | if width <= 0 || height <= 0 { | |
| 901 | + | return | |
| 902 | + | } | |
| 903 | + | ||
| 822 | 904 | rp.mu.Lock() | |
| 823 | 905 | ||
| 906 | + | prevWidth := rp.widthAtomic.Load() | |
| 907 | + | prevHeight := rp.heightAtomic.Load() | |
| 908 | + | scale := rp.scale | |
| 909 | + | if scale <= 0 { | |
| 910 | + | scale = 1 | |
| 911 | + | } | |
| 912 | + | ||
| 824 | 913 | scaled := func(v int32) int32 { | |
| 825 | - | return v * rp.scale | |
| 914 | + | return v * scale | |
| 826 | 915 | } | |
| 827 | 916 | ||
| 828 | 917 | w, h := scaled(width), scaled(height) | |
| 918 | + | resizeSeq := rp.resizeSeq.Add(1) | |
| 919 | + | now := time.Now() | |
| 920 | + | rp.lastResizeUnixNano.Store(now.UnixNano()) | |
| 829 | 921 | rp.widthAtomic.Store(w) | |
| 830 | 922 | rp.heightAtomic.Store(h) | |
| 831 | 923 | rp.sizeChanged = true | |
| @@ -837,6 +929,22 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 837 | 929 | resizeCB := rp.onResizeCB | |
| 838 | 930 | rp.mu.Unlock() | |
| 839 | 931 | ||
| 932 | + | if rp.ctx != nil { | |
| 933 | + | logging.FromContext(rp.ctx).Debug(). | |
| 934 | + | Uint64("webview_id", uint64(rp.webviewID)). | |
| 935 | + | Uint64("resize_seq", resizeSeq). | |
| 936 | + | Int32("css_width", width). | |
| 937 | + | Int32("css_height", height). | |
| 938 | + | Int32("scaled_width", w). | |
| 939 | + | Int32("scaled_height", h). | |
| 940 | + | Int32("prev_width", prevWidth). | |
| 941 | + | Int32("prev_height", prevHeight). | |
| 942 | + | Int32("scale", scale). | |
| 943 | + | Bool("first_resize", firstCB != nil). | |
| 944 | + | Bool("has_resize_cb", resizeCB != nil). | |
| 945 | + | Msg("cef: GLArea resize") | |
| 946 | + | } | |
| 947 | + | ||
| 840 | 948 | if firstCB != nil { | |
| 841 | 949 | firstCB(w, h) | |
| 842 | 950 | } else if resizeCB != nil { | |
| @@ -918,6 +1026,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||
| 918 | 1026 | return width, height, scale | |
| 919 | 1027 | } | |
| 920 | 1028 | ||
| 1029 | + | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |
| 1030 | + | resizeSeq = rp.resizeSeq.Load() | |
| 1031 | + | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |
| 1032 | + | if lastResizeUnixNano == 0 { | |
| 1033 | + | return resizeSeq, 0 | |
| 1034 | + | } | |
| 1035 | + | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |
| 1036 | + | if age <= 0 { | |
| 1037 | + | return resizeSeq, 0 | |
| 1038 | + | } | |
| 1039 | + | return resizeSeq, age.Milliseconds() | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |
| 1043 | + | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |
| 1044 | + | if resizeSeq == 0 { | |
| 1045 | + | return resizeSeq, ageMs, false | |
| 1046 | + | } | |
| 1047 | + | for { | |
| 1048 | + | lastLogged := rp.lastResizePaintLogSeq.Load() | |
| 1049 | + | if lastLogged >= resizeSeq { | |
| 1050 | + | return resizeSeq, ageMs, false | |
| 1051 | + | } | |
| 1052 | + | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1053 | + | return resizeSeq, ageMs, true | |
| 1054 | + | } | |
| 1055 | + | } | |
| 1056 | + | } | |
| 1057 | + | ||
| 1058 | + | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |
| 1059 | + | if resizeSeq == 0 { | |
| 1060 | + | return false | |
| 1061 | + | } | |
| 1062 | + | for { | |
| 1063 | + | lastLogged := rp.lastResizeStaleLogSeq.Load() | |
| 1064 | + | if lastLogged >= resizeSeq { | |
| 1065 | + | return false | |
| 1066 | + | } | |
| 1067 | + | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1068 | + | return true | |
| 1069 | + | } | |
| 1070 | + | } | |
| 1071 | + | } | |
| 1072 | + | ||
| 921 | 1073 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |
| 922 | 1074 | return rp.viewRectSeq.Add(1) | |
| 923 | 1075 | } | |
M internal/infrastructure/cef/render_pipeline_test.go +28 -0
| @@ -80,6 +80,34 @@ func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) { | |||
| 80 | 80 | require.False(t, rp.forceFullUpload) | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | + | func TestOnResizeIgnoresZeroFirstResize(t *testing.T) { | |
| 84 | + | called := false | |
| 85 | + | rp := &renderPipeline{ | |
| 86 | + | scale: 1, | |
| 87 | + | onFirstResize: func(_, _ int32) { | |
| 88 | + | called = true | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | rp.widthAtomic.Store(123) | |
| 92 | + | rp.heightAtomic.Store(456) | |
| 93 | + | ||
| 94 | + | rp.onResize(0, 0) | |
| 95 | + | ||
| 96 | + | require.False(t, called) | |
| 97 | + | require.Equal(t, int32(123), rp.widthAtomic.Load()) | |
| 98 | + | require.Equal(t, int32(456), rp.heightAtomic.Load()) | |
| 99 | + | require.False(t, rp.sizeChanged) | |
| 100 | + | require.NotNil(t, rp.onFirstResize) | |
| 101 | + | ||
| 102 | + | rp.onResize(10, 20) | |
| 103 | + | ||
| 104 | + | require.True(t, called) | |
| 105 | + | require.Equal(t, int32(10), rp.widthAtomic.Load()) | |
| 106 | + | require.Equal(t, int32(20), rp.heightAtomic.Load()) | |
| 107 | + | require.True(t, rp.sizeChanged) | |
| 108 | + | require.Nil(t, rp.onFirstResize) | |
| 109 | + | } | |
| 110 | + | ||
| 83 | 111 | // sortRects sorts rects by (X, Y) so tests can compare without caring about order. | |
| 84 | 112 | func sortRects(rects []rect) { | |
| 85 | 113 | sort.Slice(rects, func(i, j int) bool { | |
A internal/infrastructure/cef/renderer.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 4 | + | ||
| 5 | + | type Renderer = gtkmenu.Renderer | |
| 6 | + | ||
| 7 | + | func NewRenderer(dispatch func(func())) *Renderer { | |
| 8 | + | return gtkmenu.NewRenderer(dispatch) | |
| 9 | + | } | |
A internal/infrastructure/cef/renderer_bridge.go +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "unsafe" | |
| 9 | + | ||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | rendererBridgeMessageName = "dumber.renderer_bridge" | |
| 17 | + | rendererBridgeActionExplicitTextCopy = "explicit_text_copy" | |
| 18 | + | rendererBridgeActionFocusSync = "focus_sync" | |
| 19 | + | rendererBridgeActionEditableFocusChanged = "editable_focus_changed" | |
| 20 | + | rendererBridgeActionPopupOpen = "popup_open" | |
| 21 | + | rendererBridgeActionPopupNavigate = "popup_navigate" | |
| 22 | + | rendererBridgeActionPopupClose = "popup_close" | |
| 23 | + | rendererBridgeActionReady = "bridge_ready" | |
| 24 | + | rendererBridgeExtensionName = "dumber.renderer_bridge" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate | |
| 28 | + | ||
| 29 | + | type rendererBridgeProcessHandler struct { | |
| 30 | + | registerOnce sync.Once | |
| 31 | + | bridgeHandler purecef.V8Handler | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | |
| 35 | + | if h == nil { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | h.registerOnce.Do(func() { | |
| 39 | + | if h.bridgeHandler == nil { | |
| 40 | + | h.bridgeHandler = &rendererBridgeV8Handler{} | |
| 41 | + | } | |
| 42 | + | if result := purecef.RegisterExtension(rendererBridgeExtensionName, rendererBridgeExtensionJS, h.bridgeHandler); result != 1 { | |
| 43 | + | logging.FromContext(context.Background()).Error().Int32("result", result).Msg("cef: failed to register renderer bridge extension") | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (h *rendererBridgeProcessHandler) OnWebKitInitialized() { | |
| 49 | + | h.ensureExtensionRegistered() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (*rendererBridgeProcessHandler) OnBrowserCreated(_ purecef.Browser, _ purecef.DictionaryValue) {} | |
| 53 | + | func (*rendererBridgeProcessHandler) OnBrowserDestroyed(_ purecef.Browser) {} | |
| 54 | + | func (*rendererBridgeProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil } | |
| 55 | + | func (*rendererBridgeProcessHandler) OnContextCreated(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |
| 56 | + | } | |
| 57 | + | func (*rendererBridgeProcessHandler) OnContextReleased(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |
| 58 | + | } | |
| 59 | + | func (*rendererBridgeProcessHandler) OnUncaughtException( | |
| 60 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace, | |
| 61 | + | ) { | |
| 62 | + | } | |
| 63 | + | func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, frame purecef.Frame, node purecef.Domnode) { | |
| 64 | + | if frame == nil { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | message := newRendererBridgeProcessMessage(rendererBridgeMessageName) | |
| 68 | + | if message == nil { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | args := message.GetArgumentList() | |
| 72 | + | if args == nil { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | args.SetString(0, rendererBridgeActionEditableFocusChanged) | |
| 76 | + | if node != nil && node.IsEditable() { | |
| 77 | + | args.SetString(1, "1") | |
| 78 | + | } else { | |
| 79 | + | args.SetString(1, "0") | |
| 80 | + | } | |
| 81 | + | frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message) | |
| 82 | + | } | |
| 83 | + | func (*rendererBridgeProcessHandler) OnProcessMessageReceived( | |
| 84 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage, | |
| 85 | + | ) int32 { | |
| 86 | + | return 0 | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | type rendererBridgeV8Handler struct{} | |
| 90 | + | ||
| 91 | + | func (*rendererBridgeV8Handler) Execute( | |
| 92 | + | name string, _ purecef.V8Value, arguments []purecef.V8Value, _ unsafe.Pointer, _ uintptr, | |
| 93 | + | ) int32 { | |
| 94 | + | if name != "Dispatch" || len(arguments) < 2 { | |
| 95 | + | return 0 | |
| 96 | + | } | |
| 97 | + | if arguments[0] == nil || arguments[1] == nil || !arguments[0].IsString() || !arguments[1].IsString() { | |
| 98 | + | return 0 | |
| 99 | + | } | |
| 100 | + | action := arguments[0].GetStringValue() | |
| 101 | + | payload := arguments[1].GetStringValue() | |
| 102 | + | if action == "" { | |
| 103 | + | return 0 | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | ctx := purecef.V8ContextGetCurrentContext() | |
| 107 | + | if ctx == nil || !ctx.IsValid() { | |
| 108 | + | return 0 | |
| 109 | + | } | |
| 110 | + | frame := ctx.GetFrame() | |
| 111 | + | if frame == nil { | |
| 112 | + | return 0 | |
| 113 | + | } | |
| 114 | + | message := purecef.ProcessMessageCreate(rendererBridgeMessageName) | |
| 115 | + | if message == nil { | |
| 116 | + | return 0 | |
| 117 | + | } | |
| 118 | + | args := message.GetArgumentList() | |
| 119 | + | if args == nil { | |
| 120 | + | return 0 | |
| 121 | + | } | |
| 122 | + | args.SetString(0, action) | |
| 123 | + | args.SetString(1, payload) | |
| 124 | + | frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message) | |
| 125 | + | return 1 | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func decodeRendererBridgeProcessMessage(message purecef.ProcessMessage) (string, string, bool) { | |
| 129 | + | if message == nil || message.GetName() != rendererBridgeMessageName { | |
| 130 | + | return "", "", false | |
| 131 | + | } | |
| 132 | + | args := message.GetArgumentList() | |
| 133 | + | if args == nil || args.GetSize() < 2 { | |
| 134 | + | return "", "", false | |
| 135 | + | } | |
| 136 | + | action := args.GetString(0) | |
| 137 | + | payload := args.GetString(1) | |
| 138 | + | if action == "" { | |
| 139 | + | return "", "", false | |
| 140 | + | } | |
| 141 | + | return action, payload, true | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | type rendererBridgeExplicitTextCopyPayload struct { | |
| 145 | + | Text string `json:"text"` | |
| 146 | + | Action string `json:"action"` | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | type rendererBridgePopupOpenPayload struct { | |
| 150 | + | ProxyID string `json:"proxy_id"` | |
| 151 | + | URL string `json:"url"` | |
| 152 | + | FrameName string `json:"frame_name"` | |
| 153 | + | UserGesture bool `json:"user_gesture"` | |
| 154 | + | NoJavaScriptAccess bool `json:"no_javascript_access"` | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | type rendererBridgePopupNavigatePayload struct { | |
| 158 | + | ProxyID string `json:"proxy_id"` | |
| 159 | + | URL string `json:"url"` | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | type rendererBridgePopupClosePayload struct { | |
| 163 | + | ProxyID string `json:"proxy_id"` | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) { | |
| 167 | + | var req rendererBridgeExplicitTextCopyPayload | |
| 168 | + | if len(payload) == 0 { | |
| 169 | + | return req, fmt.Errorf("empty payload") | |
| 170 | + | } | |
| 171 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 172 | + | return req, err | |
| 173 | + | } | |
| 174 | + | if req.Action == "" { | |
| 175 | + | req.Action = "copy" | |
| 176 | + | } | |
| 177 | + | if req.Text == "" { | |
| 178 | + | return req, fmt.Errorf("missing text") | |
| 179 | + | } | |
| 180 | + | return req, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func decodeRendererBridgePopupPayload[T any](payload []byte, proxyID func(T) string) (T, error) { | |
| 184 | + | var req T | |
| 185 | + | if len(payload) == 0 { | |
| 186 | + | return req, fmt.Errorf("empty payload") | |
| 187 | + | } | |
| 188 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 189 | + | return req, err | |
| 190 | + | } | |
| 191 | + | if proxyID(req) == "" { | |
| 192 | + | return req, fmt.Errorf("missing proxy_id") | |
| 193 | + | } | |
| 194 | + | return req, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func decodeRendererBridgePopupOpenPayload(payload []byte) (rendererBridgePopupOpenPayload, error) { | |
| 198 | + | return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupOpenPayload) string { | |
| 199 | + | return req.ProxyID | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func decodeRendererBridgePopupNavigatePayload(payload []byte) (rendererBridgePopupNavigatePayload, error) { | |
| 204 | + | return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupNavigatePayload) string { | |
| 205 | + | return req.ProxyID | |
| 206 | + | }) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func decodeRendererBridgePopupClosePayload(payload []byte) (rendererBridgePopupClosePayload, error) { | |
| 210 | + | return decodeRendererBridgePopupPayload(payload, func(req rendererBridgePopupClosePayload) string { | |
| 211 | + | return req.ProxyID | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) { | |
| 216 | + | logger := logging.FromContext(e.currentContext()) | |
| 217 | + | task := purecef.NewTask(cefTaskFunc(func() { | |
| 218 | + | if browser == nil { | |
| 219 | + | logger.Debug().Msg("cef: editable focus sync skipped — browser nil") | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | host := browser.GetHost() | |
| 223 | + | if host == nil { | |
| 224 | + | logger.Debug().Msg("cef: editable focus sync skipped — host nil") | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | syncWindowlessBrowserFocus(host) | |
| 228 | + | logger.Debug().Msg("cef: editable focus synchronized") | |
| 229 | + | })) | |
| 230 | + | if result := purecef.PostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 231 | + | logger.Warn().Int32("result", result).Msg("cef: failed to post editable focus sync to UI thread") | |
| 232 | + | } | |
| 233 | + | } | |
A internal/infrastructure/cef/renderer_bridge_test.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type testBridgeProcessMessage struct { | |
| 18 | + | name string | |
| 19 | + | args *testBridgeListValue | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type testBridgeListValue struct { | |
| 23 | + | values map[int]string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func newTestBridgeProcessMessage(name string, editable bool) *testBridgeProcessMessage { | |
| 27 | + | msg := &testBridgeProcessMessage{name: name, args: &testBridgeListValue{values: map[int]string{}}} | |
| 28 | + | msg.args.SetString(0, rendererBridgeActionEditableFocusChanged) | |
| 29 | + | if editable { | |
| 30 | + | msg.args.SetString(1, "1") | |
| 31 | + | } else { | |
| 32 | + | msg.args.SetString(1, "0") | |
| 33 | + | } | |
| 34 | + | return msg | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (m *testBridgeProcessMessage) IsValid() bool { return true } | |
| 38 | + | func (m *testBridgeProcessMessage) IsReadOnly() bool { return false } | |
| 39 | + | func (m *testBridgeProcessMessage) Copy() purecef.ProcessMessage { return m } | |
| 40 | + | func (m *testBridgeProcessMessage) GetName() string { return m.name } | |
| 41 | + | func (m *testBridgeProcessMessage) GetArgumentList() purecef.ListValue { return m.args } | |
| 42 | + | func (m *testBridgeProcessMessage) GetSharedMemoryRegion() purecef.SharedMemoryRegion { return nil } | |
| 43 | + | ||
| 44 | + | func (v *testBridgeListValue) IsValid() bool { return true } | |
| 45 | + | func (v *testBridgeListValue) IsOwned() bool { return true } | |
| 46 | + | func (v *testBridgeListValue) IsReadOnly() bool { return false } | |
| 47 | + | func (v *testBridgeListValue) IsSame(that purecef.ListValue) bool { return v == that } | |
| 48 | + | func (v *testBridgeListValue) IsEqual(that purecef.ListValue) bool { | |
| 49 | + | return v == that | |
| 50 | + | } | |
| 51 | + | func (v *testBridgeListValue) Copy() purecef.ListValue { return v } | |
| 52 | + | func (v *testBridgeListValue) SetSize(_ int) int32 { return 0 } | |
| 53 | + | func (v *testBridgeListValue) GetSize() int { return len(v.values) } | |
| 54 | + | func (v *testBridgeListValue) Clear() int32 { v.values = map[int]string{}; return 0 } | |
| 55 | + | func (v *testBridgeListValue) Remove(index int) int32 { delete(v.values, index); return 0 } | |
| 56 | + | func (v *testBridgeListValue) GetType(_ int) purecef.ValueType { return 0 } | |
| 57 | + | func (v *testBridgeListValue) GetValue(_ int) purecef.Value { return nil } | |
| 58 | + | func (v *testBridgeListValue) GetBool(_ int) int32 { return 0 } | |
| 59 | + | func (v *testBridgeListValue) GetInt(_ int) int32 { return 0 } | |
| 60 | + | func (v *testBridgeListValue) GetDouble(_ int) float64 { return 0 } | |
| 61 | + | func (v *testBridgeListValue) GetString(index int) string { | |
| 62 | + | if v.values == nil { | |
| 63 | + | return "" | |
| 64 | + | } | |
| 65 | + | return v.values[index] | |
| 66 | + | } | |
| 67 | + | func (v *testBridgeListValue) GetBinary(_ int) purecef.BinaryValue { return nil } | |
| 68 | + | func (v *testBridgeListValue) GetDictionary(_ int) purecef.DictionaryValue { return nil } | |
| 69 | + | func (v *testBridgeListValue) GetList(_ int) purecef.ListValue { return nil } | |
| 70 | + | func (v *testBridgeListValue) SetValue(_ int, _ purecef.Value) int32 { return 0 } | |
| 71 | + | func (v *testBridgeListValue) SetNull(_ int) int32 { return 0 } | |
| 72 | + | func (v *testBridgeListValue) SetBool(_ int, _ int32) int32 { return 0 } | |
| 73 | + | func (v *testBridgeListValue) SetInt(_ int, _ int32) int32 { return 0 } | |
| 74 | + | func (v *testBridgeListValue) SetDouble(_ int, _ float64) int32 { return 0 } | |
| 75 | + | func (v *testBridgeListValue) SetString(index int, value string) int32 { | |
| 76 | + | if v.values == nil { | |
| 77 | + | v.values = map[int]string{} | |
| 78 | + | } | |
| 79 | + | v.values[index] = value | |
| 80 | + | return 0 | |
| 81 | + | } | |
| 82 | + | func (v *testBridgeListValue) SetBinary(_ int, _ purecef.BinaryValue) int32 { return 0 } | |
| 83 | + | func (v *testBridgeListValue) SetDictionary(_ int, _ purecef.DictionaryValue) int32 { return 0 } | |
| 84 | + | func (v *testBridgeListValue) SetList(_ int, _ purecef.ListValue) int32 { return 0 } | |
| 85 | + | ||
| 86 | + | func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) { | |
| 87 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge") | |
| 88 | + | require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =") | |
| 89 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.DumberBridgeDispatch") | |
| 90 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgeAction") | |
| 91 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgePayload") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing.T) { | |
| 95 | + | require.Contains(t, rendererBridgeExtensionJS, "native function Dispatch(action, payload);") | |
| 96 | + | require.Contains(t, rendererBridgeExtensionJS, "return Dispatch(action, payload == null ? '' : String(payload));") | |
| 97 | + | require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) { | |
| 101 | + | require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched") | |
| 102 | + | require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)") | |
| 103 | + | require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));") | |
| 104 | + | require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)") | |
| 105 | + | require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)") | |
| 106 | + | require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access") | |
| 107 | + | require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'") | |
| 108 | + | require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) { | |
| 112 | + | require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;") | |
| 113 | + | require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {") | |
| 114 | + | require.Contains(t, rendererBridgeExtensionJS, "if (!text && !e.defaultPrevented) text = getSelectedText();") | |
| 115 | + | require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function") | |
| 116 | + | require.Contains(t, rendererBridgeExtensionJS, "return result;") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) { | |
| 120 | + | files := parseCEFPackageFiles(t) | |
| 121 | + | for _, file := range files { | |
| 122 | + | for _, group := range file.Comments { | |
| 123 | + | for _, comment := range group.List { | |
| 124 | + | require.NotContains(t, comment.Text, "go:linkname") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | |
| 131 | + | files := parseCEFPackageFiles(t) | |
| 132 | + | for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} { | |
| 133 | + | require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |
| 138 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(`{"text":"copied text","action":"cut"}`)) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | require.Equal(t, "copied text", req.Text) | |
| 141 | + | require.Equal(t, "cut", req.Action) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestDecodeRendererBridgePopupOpenPayload(t *testing.T) { | |
| 145 | + | req, err := decodeRendererBridgePopupOpenPayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"_blank","user_gesture":true,"no_javascript_access":true}`)) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | require.Equal(t, "popup-1", req.ProxyID) | |
| 148 | + | require.Equal(t, "https://example.com/login", req.URL) | |
| 149 | + | require.Equal(t, "_blank", req.FrameName) | |
| 150 | + | require.True(t, req.UserGesture) | |
| 151 | + | require.True(t, req.NoJavaScriptAccess) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestDecodeRendererBridgePopupNavigatePayload(t *testing.T) { | |
| 155 | + | req, err := decodeRendererBridgePopupNavigatePayload([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`)) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | require.Equal(t, "popup-1", req.ProxyID) | |
| 158 | + | require.Equal(t, "https://example.com/callback", req.URL) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestDecodeRendererBridgePopupClosePayload(t *testing.T) { | |
| 162 | + | req, err := decodeRendererBridgePopupClosePayload([]byte(`{"proxy_id":"popup-1"}`)) | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | require.Equal(t, "popup-1", req.ProxyID) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestDecodeRendererBridgePopupPayloadErrors(t *testing.T) { | |
| 168 | + | tests := []struct { | |
| 169 | + | name string | |
| 170 | + | decode func([]byte) error | |
| 171 | + | }{ | |
| 172 | + | { | |
| 173 | + | name: "open", | |
| 174 | + | decode: func(payload []byte) error { | |
| 175 | + | _, err := decodeRendererBridgePopupOpenPayload(payload) | |
| 176 | + | return err | |
| 177 | + | }, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | name: "navigate", | |
| 181 | + | decode: func(payload []byte) error { | |
| 182 | + | _, err := decodeRendererBridgePopupNavigatePayload(payload) | |
| 183 | + | return err | |
| 184 | + | }, | |
| 185 | + | }, | |
| 186 | + | { | |
| 187 | + | name: "close", | |
| 188 | + | decode: func(payload []byte) error { | |
| 189 | + | _, err := decodeRendererBridgePopupClosePayload(payload) | |
| 190 | + | return err | |
| 191 | + | }, | |
| 192 | + | }, | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | payloads := []struct { | |
| 196 | + | name string | |
| 197 | + | payload []byte | |
| 198 | + | }{ | |
| 199 | + | {name: "empty", payload: nil}, | |
| 200 | + | {name: "invalid json", payload: []byte(`{invalid`)}, | |
| 201 | + | {name: "missing proxy id", payload: []byte(`{"url":"https://example.com"}`)}, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | for _, test := range tests { | |
| 205 | + | t.Run(test.name, func(t *testing.T) { | |
| 206 | + | for _, payload := range payloads { | |
| 207 | + | t.Run(payload.name, func(t *testing.T) { | |
| 208 | + | require.Error(t, test.decode(payload.payload)) | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func parseCEFPackageFiles(t *testing.T) []*ast.File { | |
| 216 | + | t.Helper() | |
| 217 | + | entries, err := os.ReadDir(".") | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | ||
| 220 | + | fset := token.NewFileSet() | |
| 221 | + | files := make([]*ast.File, 0, len(entries)) | |
| 222 | + | for _, entry := range entries { | |
| 223 | + | name := entry.Name() | |
| 224 | + | if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { | |
| 225 | + | continue | |
| 226 | + | } | |
| 227 | + | file, err := parser.ParseFile(fset, name, nil, parser.ParseComments) | |
| 228 | + | require.NoError(t, err) | |
| 229 | + | if file.Name != nil && file.Name.Name == "cef" { | |
| 230 | + | files = append(files, file) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | require.NotEmpty(t, files) | |
| 234 | + | return files | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func packageDeclaresIdentifier(files []*ast.File, name string) bool { | |
| 238 | + | for _, file := range files { | |
| 239 | + | found := false | |
| 240 | + | ast.Inspect(file, func(node ast.Node) bool { | |
| 241 | + | ident, ok := node.(*ast.Ident) | |
| 242 | + | if ok && ident.Name == name { | |
| 243 | + | found = true | |
| 244 | + | return false | |
| 245 | + | } | |
| 246 | + | return true | |
| 247 | + | }) | |
| 248 | + | if found { | |
| 249 | + | return true | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) { | |
| 256 | + | oldFactory := newRendererBridgeProcessMessage | |
| 257 | + | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |
| 258 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |
| 259 | + | return newTestBridgeProcessMessage(name, true) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | frame := cefmocks.NewMockFrame(t) | |
| 263 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |
| 264 | + | require.Equal(t, rendererBridgeMessageName, message.GetName()) | |
| 265 | + | args := message.GetArgumentList() | |
| 266 | + | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 267 | + | require.Equal(t, "1", args.GetString(1)) | |
| 268 | + | }).Once() | |
| 269 | + | ||
| 270 | + | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | |
| 271 | + | } | |
A internal/infrastructure/cef/resize_reconciler.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | resizeReconcileRetryDelay = 8 * time.Millisecond | |
| 17 | + | resizeReconcileWindow = 64 * time.Millisecond | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type resizeReconcileTimer interface { | |
| 21 | + | stop() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type resizeReconcileScheduler interface { | |
| 25 | + | afterFunc(delay time.Duration, fn func()) resizeReconcileTimer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type glibResizeTimer struct{ id uint } | |
| 29 | + | ||
| 30 | + | func (t *glibResizeTimer) stop() { | |
| 31 | + | if t == nil || t.id == 0 { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | glib.SourceRemove(t.id) | |
| 35 | + | t.id = 0 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type glibResizeScheduler struct{} | |
| 39 | + | ||
| 40 | + | func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 41 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 42 | + | if fn != nil { | |
| 43 | + | fn() | |
| 44 | + | } | |
| 45 | + | return false | |
| 46 | + | }) | |
| 47 | + | return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type resizeReconciler struct { | |
| 51 | + | mu sync.Mutex | |
| 52 | + | webviewID port.WebViewID | |
| 53 | + | ctx context.Context | |
| 54 | + | scheduler resizeReconcileScheduler | |
| 55 | + | now func() time.Time | |
| 56 | + | session *resizeReconcileSession | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type resizeReconcileSession struct { | |
| 60 | + | seq uint64 | |
| 61 | + | startedAt time.Time | |
| 62 | + | retryCount int | |
| 63 | + | timer resizeReconcileTimer | |
| 64 | + | hostGetter func() resizeNotifiableBrowserHost | |
| 65 | + | destroyed func() bool | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler { | |
| 69 | + | return &resizeReconciler{ | |
| 70 | + | webviewID: webviewID, | |
| 71 | + | ctx: ctx, | |
| 72 | + | scheduler: glibResizeScheduler{}, | |
| 73 | + | now: time.Now, | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *resizeReconciler) clock() time.Time { | |
| 78 | + | if r != nil && r.now != nil { | |
| 79 | + | return r.now() | |
| 80 | + | } | |
| 81 | + | return time.Now() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler { | |
| 85 | + | if r != nil && r.scheduler != nil { | |
| 86 | + | return r.scheduler | |
| 87 | + | } | |
| 88 | + | return glibResizeScheduler{} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) { | |
| 92 | + | if r == nil || seq == 0 || hostGetter == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | if destroyed != nil && destroyed() { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | now := r.clock() | |
| 100 | + | var superseded *resizeReconcileSession | |
| 101 | + | ||
| 102 | + | r.mu.Lock() | |
| 103 | + | if r.session != nil { | |
| 104 | + | superseded = r.session | |
| 105 | + | if superseded.timer != nil { | |
| 106 | + | superseded.timer.stop() | |
| 107 | + | } | |
| 108 | + | r.session = nil | |
| 109 | + | } | |
| 110 | + | session := &resizeReconcileSession{ | |
| 111 | + | seq: seq, | |
| 112 | + | startedAt: now, | |
| 113 | + | hostGetter: hostGetter, | |
| 114 | + | destroyed: destroyed, | |
| 115 | + | } | |
| 116 | + | r.session = session | |
| 117 | + | r.mu.Unlock() | |
| 118 | + | ||
| 119 | + | if superseded != nil { | |
| 120 | + | r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds()) | |
| 121 | + | } | |
| 122 | + | r.logStarted(seq) | |
| 123 | + | r.schedule(session) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) { | |
| 127 | + | if r == nil || seq == 0 || !sizeMatches { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | now := r.clock() | |
| 131 | + | ||
| 132 | + | r.mu.Lock() | |
| 133 | + | session := r.session | |
| 134 | + | if session == nil || session.seq != seq { | |
| 135 | + | r.mu.Unlock() | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | if session.timer != nil { | |
| 139 | + | session.timer.stop() | |
| 140 | + | session.timer = nil | |
| 141 | + | } | |
| 142 | + | r.session = nil | |
| 143 | + | retryCount := session.retryCount | |
| 144 | + | elapsed := now.Sub(session.startedAt).Milliseconds() | |
| 145 | + | r.mu.Unlock() | |
| 146 | + | ||
| 147 | + | r.logStopped("paint-arrived", seq, retryCount, elapsed) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (r *resizeReconciler) stop() { | |
| 151 | + | if r == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | r.mu.Lock() | |
| 155 | + | session := r.session | |
| 156 | + | r.session = nil | |
| 157 | + | r.mu.Unlock() | |
| 158 | + | if session != nil && session.timer != nil { | |
| 159 | + | session.timer.stop() | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (r *resizeReconciler) schedule(session *resizeReconcileSession) { | |
| 164 | + | if r == nil || session == nil { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 168 | + | r.runTick(session) | |
| 169 | + | }) | |
| 170 | + | r.mu.Lock() | |
| 171 | + | if r.session == session { | |
| 172 | + | session.timer = timer | |
| 173 | + | } else if timer != nil { | |
| 174 | + | timer.stop() | |
| 175 | + | } | |
| 176 | + | r.mu.Unlock() | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (r *resizeReconciler) runTick(session *resizeReconcileSession) { | |
| 180 | + | if r == nil || session == nil { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | now := r.clock() | |
| 184 | + | ||
| 185 | + | r.mu.Lock() | |
| 186 | + | if r.session != session { | |
| 187 | + | r.mu.Unlock() | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | if session.destroyed != nil && session.destroyed() { | |
| 191 | + | r.session = nil | |
| 192 | + | r.mu.Unlock() | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | host := session.hostGetter() | |
| 196 | + | if host == nil { | |
| 197 | + | r.session = nil | |
| 198 | + | r.mu.Unlock() | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | elapsed := now.Sub(session.startedAt) | |
| 202 | + | if elapsed > resizeReconcileWindow { | |
| 203 | + | r.session = nil | |
| 204 | + | retryCount := session.retryCount | |
| 205 | + | r.mu.Unlock() | |
| 206 | + | r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds()) | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | session.retryCount++ | |
| 210 | + | retryCount := session.retryCount | |
| 211 | + | seq := session.seq | |
| 212 | + | r.mu.Unlock() | |
| 213 | + | ||
| 214 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 215 | + | r.logRetry(seq, retryCount, elapsed.Milliseconds()) | |
| 216 | + | ||
| 217 | + | elapsedAfterInvalidate := r.clock().Sub(session.startedAt) | |
| 218 | + | r.mu.Lock() | |
| 219 | + | if r.session != session { | |
| 220 | + | r.mu.Unlock() | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if elapsedAfterInvalidate >= resizeReconcileWindow { | |
| 224 | + | r.session = nil | |
| 225 | + | r.mu.Unlock() | |
| 226 | + | r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds()) | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 230 | + | r.runTick(session) | |
| 231 | + | }) | |
| 232 | + | session.timer = newTimer | |
| 233 | + | r.mu.Unlock() | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (r *resizeReconciler) logStarted(seq uint64) { | |
| 237 | + | if r == nil || r.ctx == nil { | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | logging.FromContext(r.ctx).Debug(). | |
| 241 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 242 | + | Uint64("resize_seq", seq). | |
| 243 | + | Int("retry_count", 0). | |
| 244 | + | Int64("elapsed_ms", 0). | |
| 245 | + | Msg("cef: resize reconciliation started") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) { | |
| 249 | + | if r == nil || r.ctx == nil { | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | logging.FromContext(r.ctx).Debug(). | |
| 253 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 254 | + | Uint64("resize_seq", seq). | |
| 255 | + | Int("retry_count", retryCount). | |
| 256 | + | Int64("elapsed_ms", elapsedMs). | |
| 257 | + | Msg("cef: resize reconciliation retry") | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) { | |
| 261 | + | if r == nil || r.ctx == nil { | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | logging.FromContext(r.ctx).Debug(). | |
| 265 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 266 | + | Uint64("resize_seq", seq). | |
| 267 | + | Int("retry_count", retryCount). | |
| 268 | + | Int64("elapsed_ms", elapsedMs). | |
| 269 | + | Str("reason", reason). | |
| 270 | + | Msg("cef: resize reconciliation stopped") | |
| 271 | + | } | |
A internal/infrastructure/cef/resize_reconciler_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type resizeHostRecorder struct { | |
| 14 | + | mu sync.Mutex | |
| 15 | + | invalidations []purecef.PaintElementType | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (h *resizeHostRecorder) WasResized() {} | |
| 19 | + | ||
| 20 | + | func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |
| 21 | + | h.mu.Lock() | |
| 22 | + | defer h.mu.Unlock() | |
| 23 | + | h.invalidations = append(h.invalidations, elementType) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (h *resizeHostRecorder) invalidationCount() int { | |
| 27 | + | h.mu.Lock() | |
| 28 | + | defer h.mu.Unlock() | |
| 29 | + | return len(h.invalidations) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type fakeResizeTimer struct { | |
| 33 | + | id uint | |
| 34 | + | cancel func() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (t *fakeResizeTimer) stop() { | |
| 38 | + | if t != nil && t.cancel != nil { | |
| 39 | + | t.cancel() | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type fakeResizeScheduler struct { | |
| 44 | + | mu sync.Mutex | |
| 45 | + | nextID uint | |
| 46 | + | timers map[uint]func() | |
| 47 | + | delays []time.Duration | |
| 48 | + | stopped map[uint]bool | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newFakeResizeScheduler() *fakeResizeScheduler { | |
| 52 | + | return &fakeResizeScheduler{ | |
| 53 | + | timers: make(map[uint]func()), | |
| 54 | + | stopped: make(map[uint]bool), | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 59 | + | s.mu.Lock() | |
| 60 | + | defer s.mu.Unlock() | |
| 61 | + | s.nextID++ | |
| 62 | + | id := s.nextID | |
| 63 | + | s.timers[id] = fn | |
| 64 | + | s.delays = append(s.delays, delay) | |
| 65 | + | return &fakeResizeTimer{ | |
| 66 | + | id: id, | |
| 67 | + | cancel: func() { | |
| 68 | + | s.mu.Lock() | |
| 69 | + | defer s.mu.Unlock() | |
| 70 | + | s.stopped[id] = true | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (s *fakeResizeScheduler) fireLast() { | |
| 76 | + | s.mu.Lock() | |
| 77 | + | id := s.nextID | |
| 78 | + | fn := s.timers[id] | |
| 79 | + | stopped := s.stopped[id] | |
| 80 | + | s.mu.Unlock() | |
| 81 | + | if fn == nil || stopped { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | fn() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (s *fakeResizeScheduler) delayCount() int { | |
| 88 | + | s.mu.Lock() | |
| 89 | + | defer s.mu.Unlock() | |
| 90 | + | return len(s.delays) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) { | |
| 94 | + | clock := time.Unix(0, 0) | |
| 95 | + | scheduler := newFakeResizeScheduler() | |
| 96 | + | host := &resizeHostRecorder{} | |
| 97 | + | rr := &resizeReconciler{ | |
| 98 | + | ctx: context.Background(), | |
| 99 | + | scheduler: scheduler, | |
| 100 | + | now: func() time.Time { return clock }, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 104 | + | require.Equal(t, 1, scheduler.delayCount()) | |
| 105 | + | require.Equal(t, 0, host.invalidationCount()) | |
| 106 | + | ||
| 107 | + | clock = clock.Add(8 * time.Millisecond) | |
| 108 | + | scheduler.fireLast() | |
| 109 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 110 | + | require.Equal(t, 2, scheduler.delayCount()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) { | |
| 114 | + | clock := time.Unix(0, 0) | |
| 115 | + | scheduler := newFakeResizeScheduler() | |
| 116 | + | host := &resizeHostRecorder{} | |
| 117 | + | rr := &resizeReconciler{ | |
| 118 | + | ctx: context.Background(), | |
| 119 | + | scheduler: scheduler, | |
| 120 | + | now: func() time.Time { return clock }, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 124 | + | rr.notePaint(9, true) | |
| 125 | + | ||
| 126 | + | clock = clock.Add(8 * time.Millisecond) | |
| 127 | + | scheduler.fireLast() | |
| 128 | + | require.Zero(t, host.invalidationCount()) | |
| 129 | + | require.Equal(t, 1, scheduler.delayCount()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) { | |
| 133 | + | clock := time.Unix(0, 0) | |
| 134 | + | scheduler := newFakeResizeScheduler() | |
| 135 | + | host := &resizeHostRecorder{} | |
| 136 | + | rr := &resizeReconciler{ | |
| 137 | + | ctx: context.Background(), | |
| 138 | + | scheduler: scheduler, | |
| 139 | + | now: func() time.Time { return clock }, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 143 | + | rr.notePaint(10, false) | |
| 144 | + | ||
| 145 | + | clock = clock.Add(8 * time.Millisecond) | |
| 146 | + | scheduler.fireLast() | |
| 147 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 148 | + | require.Equal(t, 2, scheduler.delayCount()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) { | |
| 152 | + | clock := time.Unix(0, 0) | |
| 153 | + | scheduler := newFakeResizeScheduler() | |
| 154 | + | host := &resizeHostRecorder{} | |
| 155 | + | rr := &resizeReconciler{ | |
| 156 | + | ctx: context.Background(), | |
| 157 | + | scheduler: scheduler, | |
| 158 | + | now: func() time.Time { return clock }, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 162 | + | firstDelayCount := scheduler.delayCount() | |
| 163 | + | rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 164 | + | require.Equal(t, firstDelayCount+1, scheduler.delayCount()) | |
| 165 | + | ||
| 166 | + | clock = clock.Add(8 * time.Millisecond) | |
| 167 | + | scheduler.fireLast() | |
| 168 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) { | |
| 172 | + | clock := time.Unix(0, 0) | |
| 173 | + | scheduler := newFakeResizeScheduler() | |
| 174 | + | host := &resizeHostRecorder{} | |
| 175 | + | rr := &resizeReconciler{ | |
| 176 | + | ctx: context.Background(), | |
| 177 | + | scheduler: scheduler, | |
| 178 | + | now: func() time.Time { return clock }, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 182 | + | for i := 0; i < 8; i++ { | |
| 183 | + | clock = clock.Add(8 * time.Millisecond) | |
| 184 | + | scheduler.fireLast() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | require.Equal(t, 8, host.invalidationCount()) | |
| 188 | + | require.Equal(t, 8, scheduler.delayCount()) | |
| 189 | + | clock = clock.Add(8 * time.Millisecond) | |
| 190 | + | scheduler.fireLast() | |
| 191 | + | require.Equal(t, 8, host.invalidationCount()) | |
| 192 | + | } | |
M internal/infrastructure/cef/runtime_config.go +17 -13
| @@ -5,22 +5,22 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | domainerrors "github.com/bnema/dumber/internal/domain/errors" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | var ( | |
| 11 | 12 | ErrDownloadsUnsupported = errors.New("cef: downloads are not supported yet") | |
| 12 | - | ErrRelatedWebViewUnsupported = errors.New("cef: related popup webviews are not supported yet") | |
| 13 | + | ErrRelatedWebViewUnsupported = domainerrors.ErrRelatedWebViewUnsupported | |
| 13 | 14 | ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet") | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | type RuntimeConfig struct { | |
| 17 | - | CEFDir string | |
| 18 | - | LogFile string | |
| 19 | - | LogSeverity int32 | |
| 20 | - | WindowlessFrameRate int32 | |
| 21 | - | EnableAudioHandler bool | |
| 22 | - | EnableContextMenuHandler bool | |
| 23 | - | TraceHandlers bool | |
| 18 | + | CEFDir string | |
| 19 | + | LogFile string | |
| 20 | + | LogSeverity int32 | |
| 21 | + | WindowlessFrameRate int32 | |
| 22 | + | EnableAudioHandler bool | |
| 23 | + | TraceHandlers bool | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | type TranscodingRuntimeConfig struct { | |
| @@ -66,9 +66,13 @@ func (m MediaClassifier) normalize() MediaClassifier { | |||
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | type EngineDependencies struct { | |
| 69 | - | RegisterHandlers HandlerRegistrar | |
| 70 | - | RegisterAccentHandlers AccentHandlerRegistrar | |
| 71 | - | CurrentConfigPayload func() ([]byte, error) | |
| 72 | - | DefaultConfigPayload func() ([]byte, error) | |
| 73 | - | MediaClassifier MediaClassifier | |
| 69 | + | RegisterHandlers HandlerRegistrar | |
| 70 | + | RegisterAccentHandlers AccentHandlerRegistrar | |
| 71 | + | CurrentConfigPayload func() ([]byte, error) | |
| 72 | + | DefaultConfigPayload func() ([]byte, error) | |
| 73 | + | ContextMenuBuilder port.ContextMenuBuilder | |
| 74 | + | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 75 | + | Clipboard port.Clipboard | |
| 76 | + | ImageDataResolver port.ImageDataResolver | |
| 77 | + | MediaClassifier MediaClassifier | |
| 74 | 78 | } | |
M internal/infrastructure/cef/scheme_handler.go +297 -66
| @@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{ | |||
| 42 | 42 | errorPath: indexHTML, | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | var cefNewResourceHandler = purecef.NewResourceHandler | |
| 46 | + | ||
| 45 | 47 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| 46 | 48 | // internal https://dumber.invalid origin used by CEF. | |
| 47 | 49 | type dumbSchemeHandler struct { | |
| @@ -59,6 +61,23 @@ type dumbSchemeHandler struct { | |||
| 59 | 61 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | |
| 60 | 62 | // Set by the engine to write to the GDK system clipboard. | |
| 61 | 63 | onClipboardSet func(text string) | |
| 64 | + | ||
| 65 | + | // onEditableFocus is called when JS reports that an editable element gained | |
| 66 | + | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |
| 67 | + | onEditableFocus func(browser purecef.Browser) | |
| 68 | + | ||
| 69 | + | // onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open() | |
| 70 | + | // proxies from page JavaScript back into the browser process when native | |
| 71 | + | // related popups are not available (e.g. CEF OSR regular-webview fallback path). | |
| 72 | + | onPopupOpen func(browser purecef.Browser, payload rendererBridgePopupOpenPayload) | |
| 73 | + | onPopupNavigate func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload) | |
| 74 | + | onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload) | |
| 75 | + | onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload) | |
| 76 | + | onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload) | |
| 77 | + | ||
| 78 | + | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | |
| 79 | + | // browser/navigation context that issued the request. | |
| 80 | + | bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool | |
| 62 | 81 | } | |
| 63 | 82 | ||
| 64 | 83 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| @@ -99,7 +118,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||
| 99 | 118 | ||
| 100 | 119 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | |
| 101 | 120 | // for requests to either dumb:// or the internal https origin. | |
| 102 | - | func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| 121 | + | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| 103 | 122 | reqURL := request.GetURL() | |
| 104 | 123 | method := request.GetMethod() | |
| 105 | 124 | ||
| @@ -119,8 +138,18 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 119 | 138 | return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI))) | |
| 120 | 139 | } | |
| 121 | 140 | ||
| 122 | - | // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a | |
| 123 | - | // normal HTTPS origin so Chromium applies the standard web security model. | |
| 141 | + | // Route API requests before redirecting conceptual dumb:// URLs to the | |
| 142 | + | // internal HTTPS origin. External pages use dumb://api/... for clipboard, | |
| 143 | + | // focus, and popup bridges specifically to bypass page CSP; redirecting | |
| 144 | + | // those requests to https://dumber.invalid makes site connect-src policies | |
| 145 | + | // block them. | |
| 146 | + | if apiPath, ok := resolveAPIPath(u); ok { | |
| 147 | + | return h.handleAPI(browser, method, apiPath, request) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset | |
| 151 | + | // loads to a normal HTTPS origin so Chromium applies the standard web | |
| 152 | + | // security model. | |
| 124 | 153 | if isConceptualInternalURL(reqURL) { | |
| 125 | 154 | targetURL := toActualInternalURL(reqURL) | |
| 126 | 155 | if targetURL != "" && targetURL != reqURL { | |
| @@ -128,67 +157,103 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 128 | 157 | } | |
| 129 | 158 | } | |
| 130 | 159 | ||
| 131 | - | // Route API requests. | |
| 132 | - | path := u.Path | |
| 133 | - | ||
| 134 | - | if strings.HasPrefix(path, "/api/") { | |
| 135 | - | return h.handleAPI(method, path, request) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | 160 | // Serve static assets. | |
| 139 | 161 | return h.handleAsset(u) | |
| 140 | 162 | } | |
| 141 | 163 | ||
| 142 | 164 | // handleAPI routes API requests to the message router or built-in handlers. | |
| 143 | - | func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 144 | - | switch { | |
| 145 | - | case path == "/api/message" && strings.EqualFold(method, "POST"): | |
| 146 | - | body := readBodyFromHeader(request) | |
| 147 | - | if body == nil { | |
| 148 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 165 | + | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 166 | + | if strings.EqualFold(method, http.MethodOptions) { | |
| 167 | + | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |
| 168 | + | } | |
| 169 | + | if isAPIGetMethod(method) { | |
| 170 | + | if handler, ok := h.handleAPIGet(path, request); ok { | |
| 171 | + | return handler | |
| 149 | 172 | } | |
| 150 | - | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | |
| 151 | - | if err != nil { | |
| 152 | - | return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 173 | + | } | |
| 174 | + | if isAPIPostMethod(method) { | |
| 175 | + | if handler, ok := h.handleAPIPost(browser, path, request); ok { | |
| 176 | + | return handler | |
| 153 | 177 | } | |
| 154 | - | return h.newRawResourceHandler(http.StatusOK, "application/json", resp) | |
| 155 | - | ||
| 156 | - | case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")): | |
| 157 | - | return h.handleConfigAPI(h.currentConfigPayload) | |
| 158 | - | ||
| 159 | - | case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")): | |
| 160 | - | return h.handleConfigAPI(h.defaultConfigPayload) | |
| 178 | + | } | |
| 179 | + | return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 180 | + | } | |
| 161 | 181 | ||
| 162 | - | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 163 | - | return h.handleTranscodeAPI(request) | |
| 182 | + | func isAPIGetMethod(method string) bool { | |
| 183 | + | return method == "" || strings.EqualFold(method, http.MethodGet) | |
| 184 | + | } | |
| 164 | 185 | ||
| 165 | - | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 166 | - | return h.handleClipboardSet(request) | |
| 186 | + | func isAPIPostMethod(method string) bool { | |
| 187 | + | return strings.EqualFold(method, http.MethodPost) | |
| 188 | + | } | |
| 167 | 189 | ||
| 190 | + | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 191 | + | switch path { | |
| 192 | + | case "/api/config": | |
| 193 | + | return h.handleConfigAPI(h.currentConfigPayload), true | |
| 194 | + | case "/api/config/default": | |
| 195 | + | return h.handleConfigAPI(h.defaultConfigPayload), true | |
| 196 | + | case "/api/transcode": | |
| 197 | + | return h.handleTranscodeAPI(request), true | |
| 168 | 198 | default: | |
| 169 | - | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 199 | + | return nil, false | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 204 | + | switch path { | |
| 205 | + | case "/api/message": | |
| 206 | + | return h.handleMessageAPI(request), true | |
| 207 | + | case "/api/clipboard-set": | |
| 208 | + | return h.handleClipboardSet(request, browser), true | |
| 209 | + | case "/api/focus-sync": | |
| 210 | + | return h.handleFocusSync(request, browser), true | |
| 211 | + | case "/api/popup-open": | |
| 212 | + | return h.handlePopupOpen(request, browser), true | |
| 213 | + | case "/api/popup-navigate": | |
| 214 | + | return h.handlePopupNavigate(request, browser), true | |
| 215 | + | case "/api/popup-close": | |
| 216 | + | return h.handlePopupClose(request, browser), true | |
| 217 | + | case "/api/popup-opener-navigate": | |
| 218 | + | return h.handlePopupOpenerNavigate(request, browser), true | |
| 219 | + | case "/api/popup-opener-post-message": | |
| 220 | + | return h.handlePopupOpenerPostMessage(request, browser), true | |
| 221 | + | default: | |
| 222 | + | return nil, false | |
| 170 | 223 | } | |
| 171 | 224 | } | |
| 172 | 225 | ||
| 226 | + | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | |
| 227 | + | body := readBodyFromHeader(request) | |
| 228 | + | if body == nil { | |
| 229 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 230 | + | } | |
| 231 | + | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | |
| 232 | + | if err != nil { | |
| 233 | + | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 234 | + | } | |
| 235 | + | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | |
| 236 | + | } | |
| 237 | + | ||
| 173 | 238 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 174 | 239 | if h.transcoder == nil { | |
| 175 | - | return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| 240 | + | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| 176 | 241 | } | |
| 177 | 242 | ||
| 178 | 243 | reqURL := request.GetURL() | |
| 179 | 244 | parsed, err := url.Parse(reqURL) | |
| 180 | 245 | if err != nil { | |
| 181 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |
| 246 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |
| 182 | 247 | } | |
| 183 | 248 | ||
| 184 | 249 | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |
| 185 | 250 | if sourceURL == "" { | |
| 186 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |
| 251 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |
| 187 | 252 | } | |
| 188 | 253 | ||
| 189 | 254 | sourceParsed, err := url.Parse(sourceURL) | |
| 190 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |
| 191 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |
| 255 | + | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | |
| 256 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |
| 192 | 257 | } | |
| 193 | 258 | ||
| 194 | 259 | headers := make(map[string]string) | |
| @@ -210,7 +275,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 210 | 275 | Int("forwarded_header_count", len(headers)). | |
| 211 | 276 | Msg("scheme handler returning transcoding stream") | |
| 212 | 277 | ||
| 213 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 278 | + | return cefNewResourceHandler(&transcodingResourceHandler{ | |
| 214 | 279 | transcoder: h.transcoder, | |
| 215 | 280 | sourceURL: sourceURL, | |
| 216 | 281 | headers: headers, | |
| @@ -233,26 +298,37 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||
| 233 | 298 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | |
| 234 | 299 | data, err := resolveConfigPayload(build) | |
| 235 | 300 | if err != nil { | |
| 236 | - | return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 301 | + | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 237 | 302 | } | |
| 238 | - | return h.newRawResourceHandler(http.StatusOK, "application/json", data) | |
| 303 | + | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data) | |
| 239 | 304 | } | |
| 240 | 305 | ||
| 241 | 306 | // handleClipboardSet receives copied text from JS copy/cut events and writes | |
| 242 | 307 | // it to the system clipboard via the engine callback. | |
| 243 | - | const maxClipboardBytes = 10 << 20 // 10 MB | |
| 308 | + | const ( | |
| 309 | + | maxClipboardBytes = 10 << 20 // 10 MB | |
| 310 | + | dumberBodyHeaderName = "X-Dumber-Body" | |
| 311 | + | dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action" | |
| 312 | + | dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce" | |
| 313 | + | dumberBridgeActionFocusSync = "focus-sync" | |
| 314 | + | ) | |
| 244 | 315 | ||
| 245 | - | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | |
| 316 | + | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 246 | 317 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | |
| 247 | 318 | ||
| 319 | + | if !h.hasTrustedBridgeNonce(request, browser) { | |
| 320 | + | h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce") | |
| 321 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 322 | + | } | |
| 323 | + | ||
| 248 | 324 | body := readBodyFromHeader(request) | |
| 249 | 325 | if body == nil { | |
| 250 | 326 | h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)") | |
| 251 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 327 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 252 | 328 | } | |
| 253 | 329 | if len(body) > maxClipboardBytes { | |
| 254 | 330 | h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large") | |
| 255 | - | return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |
| 331 | + | return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |
| 256 | 332 | } | |
| 257 | 333 | ||
| 258 | 334 | var payload struct { | |
| @@ -260,7 +336,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||
| 260 | 336 | } | |
| 261 | 337 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | |
| 262 | 338 | h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload") | |
| 263 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |
| 339 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |
| 264 | 340 | } | |
| 265 | 341 | ||
| 266 | 342 | h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard") | |
| @@ -271,17 +347,137 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||
| 271 | 347 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | |
| 272 | 348 | } | |
| 273 | 349 | ||
| 274 | - | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 350 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 354 | + | bridgeAction := "" | |
| 355 | + | if request != nil { | |
| 356 | + | bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName)) | |
| 357 | + | } | |
| 358 | + | if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) { | |
| 359 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header") | |
| 360 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 361 | + | } | |
| 362 | + | if browser == nil { | |
| 363 | + | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |
| 364 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |
| 365 | + | } | |
| 366 | + | if !h.hasTrustedBridgeNonce(request, browser) { | |
| 367 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce") | |
| 368 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | if h.onEditableFocus != nil { | |
| 372 | + | h.onEditableFocus(browser) | |
| 373 | + | } else { | |
| 374 | + | h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired") | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func handlePopupBridgeRequest[T any]( | |
| 381 | + | h *dumbSchemeHandler, | |
| 382 | + | request purecef.Request, | |
| 383 | + | browser purecef.Browser, | |
| 384 | + | action string, | |
| 385 | + | decode func([]byte) (T, error), | |
| 386 | + | dispatch func(purecef.Browser, T), | |
| 387 | + | ) purecef.ResourceHandler { | |
| 388 | + | if browser == nil { | |
| 389 | + | h.logger.Debug().Msg("cef: " + action + " — browser unavailable") | |
| 390 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |
| 391 | + | } | |
| 392 | + | if !h.hasTrustedBridgeNonce(request, browser) { | |
| 393 | + | h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce") | |
| 394 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |
| 395 | + | } | |
| 396 | + | body := readBodyFromHeader(request) | |
| 397 | + | if body == nil { | |
| 398 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 399 | + | } | |
| 400 | + | payload, err := decode(body) | |
| 401 | + | if err != nil { | |
| 402 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) | |
| 403 | + | } | |
| 404 | + | if dispatch != nil { | |
| 405 | + | dispatch(browser, payload) | |
| 406 | + | } else { | |
| 407 | + | h.logger.Warn().Msg("cef: " + action + " — callback not wired") | |
| 408 | + | } | |
| 409 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 413 | + | return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 417 | + | return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 421 | + | return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) { | |
| 425 | + | var payload popupOpenerNavigatePayload | |
| 426 | + | if err := json.Unmarshal(body, &payload); err != nil { | |
| 427 | + | return payload, err | |
| 428 | + | } | |
| 429 | + | payload.URL = strings.TrimSpace(payload.URL) | |
| 430 | + | if payload.URL == "" { | |
| 431 | + | return payload, fmt.Errorf("missing url") | |
| 432 | + | } | |
| 433 | + | return payload, nil | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) { | |
| 437 | + | var payload popupOpenerPostMessagePayload | |
| 438 | + | if err := json.Unmarshal(body, &payload); err != nil { | |
| 439 | + | return payload, err | |
| 440 | + | } | |
| 441 | + | payload.DataKind = strings.TrimSpace(payload.DataKind) | |
| 442 | + | payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin) | |
| 443 | + | payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin) | |
| 444 | + | payload.SourceHref = strings.TrimSpace(payload.SourceHref) | |
| 445 | + | if payload.TargetOrigin == "" { | |
| 446 | + | return payload, fmt.Errorf("missing target origin") | |
| 447 | + | } | |
| 448 | + | return payload, nil | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 452 | + | return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 456 | + | return handlePopupBridgeRequest( | |
| 457 | + | h, | |
| 458 | + | request, | |
| 459 | + | browser, | |
| 460 | + | "popup-opener-post-message", | |
| 461 | + | decodePopupOpenerPostMessagePayload, | |
| 462 | + | h.onPopupOpenerPostMessage, | |
| 463 | + | ) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool { | |
| 467 | + | if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil { | |
| 468 | + | return false | |
| 469 | + | } | |
| 470 | + | bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName)) | |
| 471 | + | if bridgeNonce == "" { | |
| 472 | + | return false | |
| 473 | + | } | |
| 474 | + | return h.bridgeNonceValidator(browser, bridgeNonce) | |
| 275 | 475 | } | |
| 276 | 476 | ||
| 277 | 477 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | |
| 278 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 279 | - | contentType: "text/plain; charset=utf-8", | |
| 280 | - | statusCode: status, | |
| 281 | - | headers: map[string]string{ | |
| 282 | - | "Location": location, | |
| 283 | - | "Cache-Control": "no-store", | |
| 284 | - | }, | |
| 478 | + | return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{ | |
| 479 | + | "Location": location, | |
| 480 | + | "Cache-Control": "no-store", | |
| 285 | 481 | }) | |
| 286 | 482 | } | |
| 287 | 483 | ||
| @@ -454,32 +650,63 @@ type staticResourceHandler struct { | |||
| 454 | 650 | offset int | |
| 455 | 651 | } | |
| 456 | 652 | ||
| 457 | - | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 458 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 653 | + | func apiResponseHeaders(extra map[string]string) map[string]string { | |
| 654 | + | headers := map[string]string{ | |
| 655 | + | "Access-Control-Allow-Origin": "*", | |
| 656 | + | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |
| 657 | + | "Access-Control-Allow-Headers": strings.Join([]string{ | |
| 658 | + | "Content-Type", | |
| 659 | + | "X-Dumber-Body", | |
| 660 | + | "X-Dumber-Bridge-Action", | |
| 661 | + | "X-Dumber-Bridge-Nonce", | |
| 662 | + | }, ", "), | |
| 663 | + | "Access-Control-Max-Age": "86400", | |
| 664 | + | "Cache-Control": "no-store", | |
| 665 | + | } | |
| 666 | + | for name, value := range extra { | |
| 667 | + | headers[name] = value | |
| 668 | + | } | |
| 669 | + | return headers | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler { | |
| 673 | + | return cefNewResourceHandler(&staticResourceHandler{ | |
| 459 | 674 | data: data, | |
| 460 | 675 | contentType: contentType, | |
| 461 | 676 | statusCode: status, | |
| 677 | + | headers: headers, | |
| 462 | 678 | }) | |
| 463 | 679 | } | |
| 464 | 680 | ||
| 681 | + | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 682 | + | return newStaticResourceHandler(status, contentType, data, nil) | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 686 | + | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | |
| 687 | + | } | |
| 688 | + | ||
| 465 | 689 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | |
| 466 | 690 | escaped := html.EscapeString(msg) | |
| 467 | 691 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | |
| 468 | 692 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | |
| 469 | 693 | } | |
| 470 | 694 | ||
| 471 | - | func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler { | |
| 695 | + | func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler { | |
| 472 | 696 | data, err := json.Marshal(v) | |
| 473 | 697 | if err != nil { | |
| 474 | - | return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed") | |
| 698 | + | fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"}) | |
| 699 | + | return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback) | |
| 475 | 700 | } | |
| 476 | - | return h.newRawResourceHandler(status, "application/json", data) | |
| 701 | + | return h.newAPIRawResourceHandler(status, "application/json", data) | |
| 477 | 702 | } | |
| 478 | 703 | ||
| 479 | 704 | // Open handles the request immediately (synchronous). | |
| 480 | - | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, _ purecef.Callback) int32 { | |
| 705 | + | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | |
| 481 | 706 | // Set handleRequest = true (1) to indicate we handle it immediately. | |
| 482 | - | *(*int32)(handleRequest) = 1 | |
| 707 | + | if handleRequest != nil { | |
| 708 | + | *handleRequest = 1 | |
| 709 | + | } | |
| 483 | 710 | return 1 | |
| 484 | 711 | } | |
| 485 | 712 | ||
| @@ -491,7 +718,7 @@ func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Cal | |||
| 491 | 718 | // GetResponseHeaders sets status code, MIME type, and content length. | |
| 492 | 719 | // CEF's SetMimeType expects the MIME type without charset parameters; | |
| 493 | 720 | // the charset must be set separately via SetCharset. | |
| 494 | - | func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) { | |
| 721 | + | func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) { | |
| 495 | 722 | response.SetStatus(int32(rh.statusCode)) | |
| 496 | 723 | if text := http.StatusText(rh.statusCode); text != "" { | |
| 497 | 724 | response.SetStatusText(text) | |
| @@ -504,18 +731,20 @@ func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, r | |||
| 504 | 731 | for name, value := range rh.headers { | |
| 505 | 732 | response.SetHeaderByName(name, value, 1) | |
| 506 | 733 | } | |
| 507 | - | *(*int64)(responseLength) = int64(len(rh.data)) | |
| 734 | + | if responseLength != nil { | |
| 735 | + | *responseLength = int64(len(rh.data)) | |
| 736 | + | } | |
| 508 | 737 | } | |
| 509 | 738 | ||
| 510 | 739 | // Skip is not used for static content. | |
| 511 | - | func (rh *staticResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 { | |
| 740 | + | func (rh *staticResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |
| 512 | 741 | return 0 | |
| 513 | 742 | } | |
| 514 | 743 | ||
| 515 | 744 | // Read copies data into the output buffer. | |
| 516 | 745 | func (rh *staticResourceHandler) Read( | |
| 517 | 746 | dataOut unsafe.Pointer, bytesToRead int32, | |
| 518 | - | bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback, | |
| 747 | + | bytesRead *int32, _ purecef.ResourceReadCallback, | |
| 519 | 748 | ) int32 { | |
| 520 | 749 | if rh.offset >= len(rh.data) { | |
| 521 | 750 | return 0 | |
| @@ -532,12 +761,14 @@ func (rh *staticResourceHandler) Read( | |||
| 532 | 761 | copy(dst, rh.data[rh.offset:rh.offset+toRead]) | |
| 533 | 762 | rh.offset += toRead | |
| 534 | 763 | ||
| 535 | - | *(*int32)(bytesRead) = int32(toRead) | |
| 764 | + | if bytesRead != nil { | |
| 765 | + | *bytesRead = int32(toRead) | |
| 766 | + | } | |
| 536 | 767 | return 1 | |
| 537 | 768 | } | |
| 538 | 769 | ||
| 539 | 770 | // ReadResponse is deprecated; Read is used instead. | |
| 540 | - | func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 { | |
| 771 | + | func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |
| 541 | 772 | return 0 | |
| 542 | 773 | } | |
| 543 | 774 | ||
| @@ -552,7 +783,7 @@ func (rh *staticResourceHandler) Cancel() {} | |||
| 552 | 783 | // The JS bridge base64-encodes the JSON body into this header to avoid the | |
| 553 | 784 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | |
| 554 | 785 | func readBodyFromHeader(request purecef.Request) []byte { | |
| 555 | - | encoded := request.GetHeaderByName("X-Dumber-Body") | |
| 786 | + | encoded := request.GetHeaderByName(dumberBodyHeaderName) | |
| 556 | 787 | if encoded == "" { | |
| 557 | 788 | return nil | |
| 558 | 789 | } | |
M internal/infrastructure/cef/scheme_handler_test.go +451 -0
| @@ -2,12 +2,28 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "errors" | |
| 7 | + | "net/http" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 12 | "github.com/stretchr/testify/require" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| 15 | + | func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |
| 16 | + | response.EXPECT().SetHeaderByName("Access-Control-Allow-Origin", "*", int32(1)).Once() | |
| 17 | + | response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once() | |
| 18 | + | response.EXPECT().SetHeaderByName( | |
| 19 | + | "Access-Control-Allow-Headers", | |
| 20 | + | "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action, X-Dumber-Bridge-Nonce", | |
| 21 | + | int32(1), | |
| 22 | + | ).Once() | |
| 23 | + | response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once() | |
| 24 | + | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |
| 25 | + | } | |
| 26 | + | ||
| 11 | 27 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | |
| 12 | 28 | data, err := resolveConfigPayload(func() ([]byte, error) { | |
| 13 | 29 | return []byte(`{"engine_type":"cef"}`), nil | |
| @@ -43,3 +59,438 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||
| 43 | 59 | require.Nil(t, h) | |
| 44 | 60 | require.Contains(t, err.Error(), "default config payload builder") | |
| 45 | 61 | } | |
| 62 | + | ||
| 63 | + | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 64 | + | request := cefmocks.NewMockRequest(t) | |
| 65 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 66 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 67 | + | ||
| 68 | + | body := readBodyFromHeader(request) | |
| 69 | + | ||
| 70 | + | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestDecodePopupOpenerPostMessagePayload_RejectsMissingTargetOrigin(t *testing.T) { | |
| 74 | + | _, err := decodePopupOpenerPostMessagePayload([]byte(`{"data":"{}","data_kind":"json","target_origin":" "}`)) | |
| 75 | + | require.Error(t, err) | |
| 76 | + | require.Contains(t, err.Error(), "target origin") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestDecodePopupOpenerPostMessagePayload_TrimsAndAcceptsTargetOrigin(t *testing.T) { | |
| 80 | + | payload, err := decodePopupOpenerPostMessagePayload([]byte(`{"data":"{}","data_kind":"json","target_origin":" https://example.com ","source_origin":" https://popup.example.com ","source_href":" https://popup.example.com/callback "}`)) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Equal(t, "https://example.com", payload.TargetOrigin) | |
| 83 | + | require.Equal(t, "https://popup.example.com", payload.SourceOrigin) | |
| 84 | + | require.Equal(t, "https://popup.example.com/callback", payload.SourceHref) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |
| 88 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 89 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 90 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 91 | + | ||
| 92 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | ||
| 95 | + | const bridgeNonce = "bridge-nonce" | |
| 96 | + | browser := cefmocks.NewMockBrowser(t) | |
| 97 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 98 | + | return got == browser && gotNonce == bridgeNonce | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | var copied string | |
| 102 | + | h.onClipboardSet = func(text string) { copied = text } | |
| 103 | + | ||
| 104 | + | request := cefmocks.NewMockRequest(t) | |
| 105 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 106 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 107 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 108 | + | ||
| 109 | + | response := cefmocks.NewMockResponse(t) | |
| 110 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 111 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 112 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 113 | + | expectAPIResponseHeaders(response) | |
| 114 | + | ||
| 115 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request) | |
| 116 | + | require.NotNil(t, handler) | |
| 117 | + | ||
| 118 | + | var responseLength int64 | |
| 119 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 120 | + | require.Positive(t, responseLength) | |
| 121 | + | require.Equal(t, "copied from js", copied) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *testing.T) { | |
| 125 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 126 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 127 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 128 | + | ||
| 129 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | ||
| 132 | + | request := cefmocks.NewMockRequest(t) | |
| 133 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | |
| 134 | + | ||
| 135 | + | response := cefmocks.NewMockResponse(t) | |
| 136 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |
| 137 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |
| 138 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 139 | + | expectAPIResponseHeaders(response) | |
| 140 | + | ||
| 141 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request) | |
| 142 | + | require.NotNil(t, handler) | |
| 143 | + | ||
| 144 | + | var responseLength int64 | |
| 145 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 146 | + | require.Positive(t, responseLength) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |
| 150 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 151 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 152 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 153 | + | ||
| 154 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | ||
| 157 | + | const bridgeNonce = "bridge-nonce" | |
| 158 | + | browser := cefmocks.NewMockBrowser(t) | |
| 159 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 160 | + | return got == browser && gotNonce == bridgeNonce | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | var focused purecef.Browser | |
| 164 | + | h.onEditableFocus = func(got purecef.Browser) { focused = got } | |
| 165 | + | ||
| 166 | + | request := cefmocks.NewMockRequest(t) | |
| 167 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once() | |
| 168 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 169 | + | ||
| 170 | + | response := cefmocks.NewMockResponse(t) | |
| 171 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 172 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 173 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 174 | + | expectAPIResponseHeaders(response) | |
| 175 | + | ||
| 176 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request) | |
| 177 | + | require.NotNil(t, handler) | |
| 178 | + | ||
| 179 | + | var responseLength int64 | |
| 180 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 181 | + | require.Positive(t, responseLength) | |
| 182 | + | require.Same(t, browser, focused) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) { | |
| 186 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 187 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 188 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 189 | + | ||
| 190 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | ||
| 193 | + | const bridgeNonce = "bridge-nonce" | |
| 194 | + | browser := cefmocks.NewMockBrowser(t) | |
| 195 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 196 | + | return got == browser && gotNonce == bridgeNonce | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | var copied string | |
| 200 | + | h.onClipboardSet = func(text string) { copied = text } | |
| 201 | + | ||
| 202 | + | request := cefmocks.NewMockRequest(t) | |
| 203 | + | request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once() | |
| 204 | + | request.EXPECT().GetMethod().Return(http.MethodPost).Once() | |
| 205 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 206 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`)) | |
| 207 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 208 | + | ||
| 209 | + | response := cefmocks.NewMockResponse(t) | |
| 210 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 211 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 212 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 213 | + | expectAPIResponseHeaders(response) | |
| 214 | + | ||
| 215 | + | handler := h.Create(browser, nil, "", request) | |
| 216 | + | require.NotNil(t, handler) | |
| 217 | + | ||
| 218 | + | var responseLength int64 | |
| 219 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 220 | + | require.Positive(t, responseLength) | |
| 221 | + | require.Equal(t, "copied from create", copied) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |
| 225 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 226 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 227 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 228 | + | ||
| 229 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | browser := cefmocks.NewMockBrowser(t) | |
| 233 | + | h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false } | |
| 234 | + | ||
| 235 | + | request := cefmocks.NewMockRequest(t) | |
| 236 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return("invalid").Once() | |
| 237 | + | ||
| 238 | + | response := cefmocks.NewMockResponse(t) | |
| 239 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |
| 240 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |
| 241 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 242 | + | expectAPIResponseHeaders(response) | |
| 243 | + | ||
| 244 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request) | |
| 245 | + | require.NotNil(t, handler) | |
| 246 | + | ||
| 247 | + | var responseLength int64 | |
| 248 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 249 | + | require.Positive(t, responseLength) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) { | |
| 253 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 254 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 255 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 256 | + | ||
| 257 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | ||
| 260 | + | const bridgeNonce = "bridge-nonce" | |
| 261 | + | browser := cefmocks.NewMockBrowser(t) | |
| 262 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 263 | + | return got == browser && gotNonce == bridgeNonce | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | var got rendererBridgePopupOpenPayload | |
| 267 | + | h.onPopupOpen = func(gotBrowser purecef.Browser, payload rendererBridgePopupOpenPayload) { | |
| 268 | + | require.Same(t, browser, gotBrowser) | |
| 269 | + | got = payload | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | request := cefmocks.NewMockRequest(t) | |
| 273 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 274 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/login","frame_name":"Google login","user_gesture":true,"no_javascript_access":true}`)) | |
| 275 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 276 | + | ||
| 277 | + | response := cefmocks.NewMockResponse(t) | |
| 278 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 279 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 280 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 281 | + | expectAPIResponseHeaders(response) | |
| 282 | + | ||
| 283 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/popup-open", request) | |
| 284 | + | require.NotNil(t, handler) | |
| 285 | + | ||
| 286 | + | var responseLength int64 | |
| 287 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 288 | + | require.Positive(t, responseLength) | |
| 289 | + | require.Equal(t, "popup-1", got.ProxyID) | |
| 290 | + | require.Equal(t, "https://example.com/login", got.URL) | |
| 291 | + | require.Equal(t, "Google login", got.FrameName) | |
| 292 | + | require.True(t, got.UserGesture) | |
| 293 | + | require.True(t, got.NoJavaScriptAccess) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) { | |
| 297 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 298 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 299 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 300 | + | ||
| 301 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | ||
| 304 | + | const bridgeNonce = "bridge-nonce" | |
| 305 | + | browser := cefmocks.NewMockBrowser(t) | |
| 306 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 307 | + | return got == browser && gotNonce == bridgeNonce | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | var got rendererBridgePopupNavigatePayload | |
| 311 | + | h.onPopupNavigate = func(gotBrowser purecef.Browser, payload rendererBridgePopupNavigatePayload) { | |
| 312 | + | require.Same(t, browser, gotBrowser) | |
| 313 | + | got = payload | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | request := cefmocks.NewMockRequest(t) | |
| 317 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 318 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1","url":"https://example.com/callback"}`)) | |
| 319 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 320 | + | ||
| 321 | + | response := cefmocks.NewMockResponse(t) | |
| 322 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 323 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 324 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 325 | + | expectAPIResponseHeaders(response) | |
| 326 | + | ||
| 327 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/popup-navigate", request) | |
| 328 | + | require.NotNil(t, handler) | |
| 329 | + | ||
| 330 | + | var responseLength int64 | |
| 331 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 332 | + | require.Positive(t, responseLength) | |
| 333 | + | require.Equal(t, "popup-1", got.ProxyID) | |
| 334 | + | require.Equal(t, "https://example.com/callback", got.URL) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) { | |
| 338 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 339 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 340 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 341 | + | ||
| 342 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | ||
| 345 | + | const bridgeNonce = "bridge-nonce" | |
| 346 | + | browser := cefmocks.NewMockBrowser(t) | |
| 347 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 348 | + | return got == browser && gotNonce == bridgeNonce | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | var got rendererBridgePopupClosePayload | |
| 352 | + | h.onPopupClose = func(gotBrowser purecef.Browser, payload rendererBridgePopupClosePayload) { | |
| 353 | + | require.Same(t, browser, gotBrowser) | |
| 354 | + | got = payload | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | request := cefmocks.NewMockRequest(t) | |
| 358 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 359 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"proxy_id":"popup-1"}`)) | |
| 360 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 361 | + | ||
| 362 | + | response := cefmocks.NewMockResponse(t) | |
| 363 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| 364 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |
| 365 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 366 | + | expectAPIResponseHeaders(response) | |
| 367 | + | ||
| 368 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/popup-close", request) | |
| 369 | + | require.NotNil(t, handler) | |
| 370 | + | ||
| 371 | + | var responseLength int64 | |
| 372 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 373 | + | require.Positive(t, responseLength) | |
| 374 | + | require.Equal(t, "popup-1", got.ProxyID) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) { | |
| 378 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 379 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 380 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 381 | + | ||
| 382 | + | tests := []struct { | |
| 383 | + | name string | |
| 384 | + | path string | |
| 385 | + | body string | |
| 386 | + | wire func(*dumbSchemeHandler, *bool) | |
| 387 | + | }{ | |
| 388 | + | { | |
| 389 | + | name: "open malformed json", | |
| 390 | + | path: "/api/popup-open", | |
| 391 | + | body: `{invalid`, | |
| 392 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 393 | + | h.onPopupOpen = func(purecef.Browser, rendererBridgePopupOpenPayload) { *called = true } | |
| 394 | + | }, | |
| 395 | + | }, | |
| 396 | + | { | |
| 397 | + | name: "open missing proxy id", | |
| 398 | + | path: "/api/popup-open", | |
| 399 | + | body: `{"url":"https://example.com/login"}`, | |
| 400 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 401 | + | h.onPopupOpen = func(purecef.Browser, rendererBridgePopupOpenPayload) { *called = true } | |
| 402 | + | }, | |
| 403 | + | }, | |
| 404 | + | { | |
| 405 | + | name: "navigate malformed json", | |
| 406 | + | path: "/api/popup-navigate", | |
| 407 | + | body: `{invalid`, | |
| 408 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 409 | + | h.onPopupNavigate = func(purecef.Browser, rendererBridgePopupNavigatePayload) { *called = true } | |
| 410 | + | }, | |
| 411 | + | }, | |
| 412 | + | { | |
| 413 | + | name: "navigate missing proxy id", | |
| 414 | + | path: "/api/popup-navigate", | |
| 415 | + | body: `{"url":"https://example.com/callback"}`, | |
| 416 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 417 | + | h.onPopupNavigate = func(purecef.Browser, rendererBridgePopupNavigatePayload) { *called = true } | |
| 418 | + | }, | |
| 419 | + | }, | |
| 420 | + | { | |
| 421 | + | name: "close malformed json", | |
| 422 | + | path: "/api/popup-close", | |
| 423 | + | body: `{invalid`, | |
| 424 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 425 | + | h.onPopupClose = func(purecef.Browser, rendererBridgePopupClosePayload) { *called = true } | |
| 426 | + | }, | |
| 427 | + | }, | |
| 428 | + | { | |
| 429 | + | name: "close missing proxy id", | |
| 430 | + | path: "/api/popup-close", | |
| 431 | + | body: `{"url":"https://example.com/callback"}`, | |
| 432 | + | wire: func(h *dumbSchemeHandler, called *bool) { | |
| 433 | + | h.onPopupClose = func(purecef.Browser, rendererBridgePopupClosePayload) { *called = true } | |
| 434 | + | }, | |
| 435 | + | }, | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | for _, test := range tests { | |
| 439 | + | t.Run(test.name, func(t *testing.T) { | |
| 440 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 441 | + | require.NoError(t, err) | |
| 442 | + | ||
| 443 | + | const bridgeNonce = "bridge-nonce" | |
| 444 | + | browser := cefmocks.NewMockBrowser(t) | |
| 445 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 446 | + | return got == browser && gotNonce == bridgeNonce | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | called := false | |
| 450 | + | test.wire(h, &called) | |
| 451 | + | ||
| 452 | + | request := cefmocks.NewMockRequest(t) | |
| 453 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 454 | + | encoded := base64.StdEncoding.EncodeToString([]byte(test.body)) | |
| 455 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 456 | + | ||
| 457 | + | response := cefmocks.NewMockResponse(t) | |
| 458 | + | response.EXPECT().SetStatus(int32(http.StatusBadRequest)).Once() | |
| 459 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusBadRequest)).Once() | |
| 460 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 461 | + | expectAPIResponseHeaders(response) | |
| 462 | + | ||
| 463 | + | handler := h.handleAPI(browser, http.MethodPost, test.path, request) | |
| 464 | + | require.NotNil(t, handler) | |
| 465 | + | ||
| 466 | + | var responseLength int64 | |
| 467 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 468 | + | require.Positive(t, responseLength) | |
| 469 | + | require.False(t, called) | |
| 470 | + | }) | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |
| 475 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 476 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 477 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 478 | + | ||
| 479 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 480 | + | require.NoError(t, err) | |
| 481 | + | ||
| 482 | + | request := cefmocks.NewMockRequest(t) | |
| 483 | + | response := cefmocks.NewMockResponse(t) | |
| 484 | + | response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once() | |
| 485 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once() | |
| 486 | + | response.EXPECT().SetMimeType("text/plain").Once() | |
| 487 | + | response.EXPECT().SetCharset("utf-8").Once() | |
| 488 | + | expectAPIResponseHeaders(response) | |
| 489 | + | ||
| 490 | + | handler := h.handleAPI(nil, http.MethodOptions, "/api/clipboard-set", request) | |
| 491 | + | require.NotNil(t, handler) | |
| 492 | + | ||
| 493 | + | var responseLength int64 | |
| 494 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 495 | + | require.Zero(t, responseLength) | |
| 496 | + | } | |
A internal/infrastructure/cef/session_spawn_environment.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | |
| 11 | + | // when restoring a session into a freshly spawned browser process. | |
| 12 | + | type SessionSpawnEnvironment struct { | |
| 13 | + | cefUserDataDir string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewSessionSpawnEnvironment(cefUserDataDir string) SessionSpawnEnvironment { | |
| 17 | + | return SessionSpawnEnvironment{cefUserDataDir: cefUserDataDir} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | |
| 21 | + | return CEFRootCachePathEnvVar | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 25 | + | return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | |
A internal/infrastructure/cef/testutil_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | type stubMenuModel struct{} | |
| 8 | + | ||
| 9 | + | func (stubMenuModel) IsSubMenu() bool { return false } | |
| 10 | + | func (stubMenuModel) Clear() int32 { panic("unexpected call") } | |
| 11 | + | func (stubMenuModel) GetCount() int { return 1 } | |
| 12 | + | func (stubMenuModel) AddSeparator() int32 { panic("unexpected call") } | |
| 13 | + | func (stubMenuModel) AddItem(_ int32, _ string) int32 { panic("unexpected call") } | |
| 14 | + | func (stubMenuModel) AddCheckItem(_ int32, _ string) int32 { | |
| 15 | + | panic("unexpected call") | |
| 16 | + | } | |
| 17 | + | func (stubMenuModel) AddRadioItem(_ int32, _ string, _ int32) int32 { | |
| 18 | + | panic("unexpected call") | |
| 19 | + | } | |
| 20 | + | func (stubMenuModel) AddSubMenu(_ int32, _ string) purecef.MenuModel { | |
| 21 | + | panic("unexpected call") | |
| 22 | + | } | |
| 23 | + | func (stubMenuModel) InsertSeparatorAt(_ int) int32 { panic("unexpected call") } | |
| 24 | + | func (stubMenuModel) InsertItemAt(_ int, _ int32, _ string) int32 { | |
| 25 | + | panic("unexpected call") | |
| 26 | + | } | |
| 27 | + | func (stubMenuModel) InsertCheckItemAt(_ int, _ int32, _ string) int32 { | |
| 28 | + | panic("unexpected call") | |
| 29 | + | } | |
| 30 | + | func (stubMenuModel) InsertRadioItemAt(_ int, _ int32, _ string, _ int32) int32 { | |
| 31 | + | panic("unexpected call") | |
| 32 | + | } | |
| 33 | + | func (stubMenuModel) InsertSubMenuAt(_ int, _ int32, _ string) purecef.MenuModel { | |
| 34 | + | panic("unexpected call") | |
| 35 | + | } | |
| 36 | + | func (stubMenuModel) Remove(_ int32) int32 { panic("unexpected call") } | |
| 37 | + | func (stubMenuModel) RemoveAt(_ int) int32 { panic("unexpected call") } | |
| 38 | + | func (stubMenuModel) GetIndexOf(_ int32) int32 { panic("unexpected call") } | |
| 39 | + | func (stubMenuModel) GetCommandIDAt(_ int) int32 { return 101 } | |
| 40 | + | func (stubMenuModel) SetCommandIDAt(_ int, _ int32) int32 { panic("unexpected call") } | |
| 41 | + | func (stubMenuModel) GetLabel(_ int32) string { panic("unexpected call") } | |
| 42 | + | func (stubMenuModel) GetLabelAt(_ int) string { return "Copy" } | |
| 43 | + | func (stubMenuModel) SetLabel(_ int32, _ string) int32 { panic("unexpected call") } | |
| 44 | + | func (stubMenuModel) SetLabelAt(_ int, _ string) int32 { panic("unexpected call") } | |
| 45 | + | func (stubMenuModel) GetType(_ int32) purecef.MenuItemType { panic("unexpected call") } | |
| 46 | + | func (stubMenuModel) GetTypeAt(_ int) purecef.MenuItemType { | |
| 47 | + | return purecef.MenuItemTypeMenuitemtypeCommand | |
| 48 | + | } | |
| 49 | + | func (stubMenuModel) GetGroupID(_ int32) int32 { panic("unexpected call") } | |
| 50 | + | func (stubMenuModel) GetGroupIDAt(_ int) int32 { panic("unexpected call") } | |
| 51 | + | func (stubMenuModel) SetGroupID(_, _ int32) int32 { panic("unexpected call") } | |
| 52 | + | func (stubMenuModel) SetGroupIDAt(_ int, _ int32) int32 { panic("unexpected call") } | |
| 53 | + | func (stubMenuModel) GetSubMenu(_ int32) purecef.MenuModel { panic("unexpected call") } | |
| 54 | + | func (stubMenuModel) GetSubMenuAt(_ int) purecef.MenuModel { panic("unexpected call") } | |
| 55 | + | func (stubMenuModel) IsVisible(_ int32) bool { panic("unexpected call") } | |
| 56 | + | func (stubMenuModel) IsVisibleAt(_ int) bool { panic("unexpected call") } | |
| 57 | + | func (stubMenuModel) SetVisible(_, _ int32) int32 { panic("unexpected call") } | |
| 58 | + | func (stubMenuModel) SetVisibleAt(_ int, _ int32) int32 { panic("unexpected call") } | |
| 59 | + | func (stubMenuModel) IsEnabled(_ int32) bool { panic("unexpected call") } | |
| 60 | + | func (stubMenuModel) IsEnabledAt(_ int) bool { panic("unexpected call") } | |
| 61 | + | func (stubMenuModel) SetEnabled(_, _ int32) int32 { panic("unexpected call") } | |
| 62 | + | func (stubMenuModel) SetEnabledAt(_ int, _ int32) int32 { panic("unexpected call") } | |
| 63 | + | func (stubMenuModel) IsChecked(_ int32) bool { panic("unexpected call") } | |
| 64 | + | func (stubMenuModel) IsCheckedAt(_ int) bool { panic("unexpected call") } | |
| 65 | + | func (stubMenuModel) SetChecked(_, _ int32) int32 { panic("unexpected call") } | |
| 66 | + | func (stubMenuModel) SetCheckedAt(_ int, _ int32) int32 { panic("unexpected call") } | |
| 67 | + | func (stubMenuModel) HasAccelerator(_ int32) bool { panic("unexpected call") } | |
| 68 | + | func (stubMenuModel) HasAcceleratorAt(_ int) bool { panic("unexpected call") } | |
| 69 | + | func (stubMenuModel) SetAccelerator(_, _, _, _, _ int32) int32 { | |
| 70 | + | panic("unexpected call") | |
| 71 | + | } | |
| 72 | + | func (stubMenuModel) SetAcceleratorAt(_ int, _, _, _, _ int32) int32 { | |
| 73 | + | panic("unexpected call") | |
| 74 | + | } | |
| 75 | + | func (stubMenuModel) RemoveAccelerator(_ int32) int32 { panic("unexpected call") } | |
| 76 | + | func (stubMenuModel) RemoveAcceleratorAt(_ int) int32 { panic("unexpected call") } | |
| 77 | + | func (stubMenuModel) GetAccelerator(_ int32, _, _, _, _ *int32) int32 { | |
| 78 | + | panic("unexpected call") | |
| 79 | + | } | |
| 80 | + | func (stubMenuModel) GetAcceleratorAt(_ int, _, _, _, _ *int32) int32 { | |
| 81 | + | panic("unexpected call") | |
| 82 | + | } | |
| 83 | + | func (stubMenuModel) SetColor(_ int32, _ purecef.MenuColorType, _ uintptr) int32 { | |
| 84 | + | panic("unexpected call") | |
| 85 | + | } | |
| 86 | + | func (stubMenuModel) SetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int32 { | |
| 87 | + | panic("unexpected call") | |
| 88 | + | } | |
| 89 | + | func (stubMenuModel) GetColor(_ int32, _ purecef.MenuColorType, _ uintptr) int32 { | |
| 90 | + | panic("unexpected call") | |
| 91 | + | } | |
| 92 | + | func (stubMenuModel) GetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int32 { | |
| 93 | + | panic("unexpected call") | |
| 94 | + | } | |
| 95 | + | func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") } | |
| 96 | + | func (stubMenuModel) SetFontListAt(_ int32, _ string) int32 { panic("unexpected call") } | |
M internal/infrastructure/cef/transcoding_handler.go +15 -9
| @@ -47,10 +47,12 @@ type transcodingResourceHandler struct { | |||
| 47 | 47 | // Open starts the transcode session asynchronously. CEF will call | |
| 48 | 48 | // GetResponseHeaders and Read once the callback fires. | |
| 49 | 49 | func (rh *transcodingResourceHandler) Open( | |
| 50 | - | _ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback, | |
| 50 | + | _ purecef.Request, handleRequest *int32, callback purecef.Callback, | |
| 51 | 51 | ) int32 { | |
| 52 | 52 | // Signal async handling: set handleRequest = 0. | |
| 53 | - | *(*int32)(handleRequest) = 0 | |
| 53 | + | if handleRequest != nil { | |
| 54 | + | *handleRequest = 0 | |
| 55 | + | } | |
| 54 | 56 | ||
| 55 | 57 | log := rh.logger() | |
| 56 | 58 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| @@ -84,7 +86,7 @@ func (rh *transcodingResourceHandler) Open( | |||
| 84 | 86 | ||
| 85 | 87 | // GetResponseHeaders sets the streaming response metadata. | |
| 86 | 88 | func (rh *transcodingResourceHandler) GetResponseHeaders( | |
| 87 | - | response purecef.Response, responseLength unsafe.Pointer, _ uintptr, | |
| 89 | + | response purecef.Response, responseLength *int64, _ uintptr, | |
| 88 | 90 | ) { | |
| 89 | 91 | response.SetStatus(httpStatusOK) | |
| 90 | 92 | response.SetStatusText("OK") | |
| @@ -96,13 +98,15 @@ func (rh *transcodingResourceHandler) GetResponseHeaders( | |||
| 96 | 98 | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |
| 97 | 99 | response.SetHeaderByName("Cache-Control", "no-store", 1) | |
| 98 | 100 | // Streaming — unknown length. | |
| 99 | - | *(*int64)(responseLength) = -1 | |
| 101 | + | if responseLength != nil { | |
| 102 | + | *responseLength = -1 | |
| 103 | + | } | |
| 100 | 104 | } | |
| 101 | 105 | ||
| 102 | 106 | // Read copies transcoded data from the session into the CEF output buffer. | |
| 103 | 107 | func (rh *transcodingResourceHandler) Read( | |
| 104 | 108 | dataOut unsafe.Pointer, bytesToRead int32, | |
| 105 | - | bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback, | |
| 109 | + | bytesRead *int32, _ purecef.ResourceReadCallback, | |
| 106 | 110 | ) int32 { | |
| 107 | 111 | if rh.session == nil { | |
| 108 | 112 | return 0 | |
| @@ -118,7 +122,9 @@ func (rh *transcodingResourceHandler) Read( | |||
| 118 | 122 | Int("first_chunk_bytes", n). | |
| 119 | 123 | Msg("cef: transcoding resource stream produced first bytes") | |
| 120 | 124 | } | |
| 121 | - | *(*int32)(bytesRead) = int32(n) | |
| 125 | + | if bytesRead != nil { | |
| 126 | + | *bytesRead = int32(n) | |
| 127 | + | } | |
| 122 | 128 | return 1 | |
| 123 | 129 | } | |
| 124 | 130 | if err != nil { | |
| @@ -166,12 +172,12 @@ func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purece | |||
| 166 | 172 | } | |
| 167 | 173 | ||
| 168 | 174 | // ReadResponse is deprecated; Read is used instead. | |
| 169 | - | func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 { | |
| 175 | + | func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |
| 170 | 176 | return 0 | |
| 171 | 177 | } | |
| 172 | 178 | ||
| 173 | 179 | // Skip is not used for streaming content. | |
| 174 | - | func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 { | |
| 180 | + | func (rh *transcodingResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |
| 175 | 181 | return 0 | |
| 176 | 182 | } | |
| 177 | 183 | ||
| @@ -360,7 +366,7 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete( | |||
| 360 | 366 | ) { | |
| 361 | 367 | } | |
| 362 | 368 | ||
| 363 | - | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | |
| 369 | + | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ *int32) { | |
| 364 | 370 | } | |
| 365 | 371 | ||
| 366 | 372 | func buildRequestInfo(request purecef.Request) requestInfo { | |
M internal/infrastructure/cef/webview.go +979 -29
| @@ -2,12 +2,16 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "encoding/base64" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "fmt" | |
| 8 | 10 | "math" | |
| 11 | + | "strings" | |
| 9 | 12 | "sync" | |
| 10 | 13 | "sync/atomic" | |
| 14 | + | "time" | |
| 11 | 15 | ||
| 12 | 16 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 17 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -19,9 +23,12 @@ import ( | |||
| 19 | 23 | ||
| 20 | 24 | // Compile-time interface checks. | |
| 21 | 25 | var ( | |
| 22 | - | _ port.WebView = (*WebView)(nil) | |
| 23 | - | _ port.NativeWidgetProvider = (*WebView)(nil) | |
| 24 | - | _ port.DevToolsOpener = (*WebView)(nil) | |
| 26 | + | _ port.WebView = (*WebView)(nil) | |
| 27 | + | _ port.NativeWidgetProvider = (*WebView)(nil) | |
| 28 | + | _ port.DevToolsOpener = (*WebView)(nil) | |
| 29 | + | _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 30 | + | _ port.PopupOpenerCapable = (*WebView)(nil) | |
| 31 | + | _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 25 | 32 | ) | |
| 26 | 33 | ||
| 27 | 34 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | |
| @@ -30,19 +37,47 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||
| 30 | 37 | // errNoBrowser is returned when the browser has not been created yet. | |
| 31 | 38 | var errNoBrowser = errors.New("cef: browser not yet created") | |
| 32 | 39 | ||
| 40 | + | const ( | |
| 41 | + | clipboardSelectionDebounceDelay = 300 * time.Millisecond | |
| 42 | + | pendingNavigationRetryDelay = 50 * time.Millisecond | |
| 43 | + | pendingNavigationMaxRetries = 80 | |
| 44 | + | nativePopupAttachFallbackDelay = 250 * time.Millisecond | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | type stoppableTimer interface { | |
| 48 | + | Stop() bool | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | var cefLoadWatchdogDelays = []time.Duration{ | |
| 52 | + | 250 * time.Millisecond, | |
| 53 | + | 1 * time.Second, | |
| 54 | + | 2 * time.Second, | |
| 55 | + | 5 * time.Second, | |
| 56 | + | 15 * time.Second, | |
| 57 | + | 30 * time.Second, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var ( | |
| 61 | + | cefNewTask = purecef.NewTask | |
| 62 | + | cefPostTask = purecef.PostTask | |
| 63 | + | cefPostDelayedTask = purecef.PostDelayedTask | |
| 64 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 65 | + | ) | |
| 66 | + | ||
| 33 | 67 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 34 | 68 | // through a renderPipeline and driven by an inputBridge. | |
| 35 | 69 | type WebView struct { | |
| 36 | - | id port.WebViewID | |
| 37 | - | ctx context.Context | |
| 38 | - | engine *Engine | |
| 39 | - | browser purecef.Browser | |
| 40 | - | host purecef.BrowserHost | |
| 41 | - | client purecef.Client // prevent GC from collecting the client before CEF AddRef's it | |
| 42 | - | pipeline *renderPipeline | |
| 43 | - | input *inputBridge | |
| 44 | - | handlers *handlerSet | |
| 45 | - | findCtrl *cefFindController | |
| 70 | + | id port.WebViewID | |
| 71 | + | ctx context.Context | |
| 72 | + | engine *Engine | |
| 73 | + | browser purecef.Browser | |
| 74 | + | host purecef.BrowserHost | |
| 75 | + | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 76 | + | pipeline *renderPipeline | |
| 77 | + | input *inputBridge | |
| 78 | + | handlers *handlerSet | |
| 79 | + | findCtrl *cefFindController | |
| 80 | + | resizeReconciler *resizeReconciler | |
| 46 | 81 | ||
| 47 | 82 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 48 | 83 | // widget is visible. Access is guarded by mu. | |
| @@ -63,17 +98,60 @@ type WebView struct { | |||
| 63 | 98 | mu sync.RWMutex | |
| 64 | 99 | callbacks *port.WebViewCallbacks | |
| 65 | 100 | ||
| 101 | + | // Synthetic popup proxies created by the renderer bridge's window.open shim. | |
| 102 | + | syntheticPopupMu sync.Mutex | |
| 103 | + | syntheticPopups map[string]*syntheticPopupState | |
| 104 | + | ||
| 105 | + | // Programmatic popup lifecycle callbacks used for OAuth auto-close and | |
| 106 | + | // synthetic window.open() proxy support on CEF. | |
| 107 | + | closeCallbacks []func() | |
| 108 | + | navigationCallbacks []func(string) | |
| 109 | + | openerMessageCallbacks []func() | |
| 110 | + | openerNavigationCallbacks []func(string) | |
| 111 | + | popupReadyToShow func() | |
| 112 | + | popupReadyShown bool | |
| 113 | + | ||
| 114 | + | // Native popup bookkeeping. CreateRelated() returns a popup shell with no | |
| 115 | + | // browser attached yet; OnBeforePopup may wire CEF's real popup browser into | |
| 116 | + | // that shell. If native attachment is unavailable or stalls, the shell can | |
| 117 | + | // still create its own browser directly while preserving the same popup pane | |
| 118 | + | // lifecycle from the coordinator's perspective. | |
| 119 | + | nativePopupCandidate bool | |
| 120 | + | nativePopupParent *WebView | |
| 121 | + | nativePopupID int32 | |
| 122 | + | nativePopupFallbackStarted bool | |
| 123 | + | nativePopupFallbackTimer stoppableTimer | |
| 124 | + | nativePopupFallbackSchedule func(time.Duration, func()) stoppableTimer | |
| 125 | + | pendingNativePopups map[int32]*WebView | |
| 126 | + | popupNoJavaScriptAccess bool | |
| 127 | + | popupOpenerBridgeParent *WebView | |
| 128 | + | popupOpenerBridgeParentURI string | |
| 129 | + | ||
| 66 | 130 | // State cache (mutex-protected). | |
| 67 | - | uri string | |
| 68 | - | title string | |
| 69 | - | progress float64 | |
| 70 | - | canGoBack bool | |
| 71 | - | canGoFwd bool | |
| 72 | - | isLoading bool | |
| 131 | + | uri string | |
| 132 | + | title string | |
| 133 | + | progress float64 | |
| 134 | + | canGoBack bool | |
| 135 | + | canGoFwd bool | |
| 136 | + | isLoading bool | |
| 137 | + | selectedText string | |
| 138 | + | focusedEditable bool | |
| 139 | + | bridgeNonce string | |
| 140 | + | selectionDebounceTimer stoppableTimer | |
| 141 | + | selectionDebounceSeq uint64 | |
| 142 | + | selectionDebounceDelay *time.Duration | |
| 143 | + | selectionDebounceSchedule func(time.Duration, func()) stoppableTimer | |
| 73 | 144 | ||
| 74 | 145 | // Last known hover URI for middle-click → new tab. | |
| 75 | 146 | lastHoverURI string | |
| 76 | 147 | ||
| 148 | + | // Load diagnostics state (mutex-protected). | |
| 149 | + | loadDiagSeq uint64 | |
| 150 | + | loadDiagStartedAt time.Time | |
| 151 | + | loadDiagLastProgressAt time.Time | |
| 152 | + | loadDiagLastAddressAt time.Time | |
| 153 | + | loadDiagLastLoadStateAt time.Time | |
| 154 | + | ||
| 77 | 155 | // Atomic state. | |
| 78 | 156 | destroyed atomic.Bool | |
| 79 | 157 | fullscreen atomic.Bool | |
| @@ -81,6 +159,11 @@ type WebView struct { | |||
| 81 | 159 | audioPlaying atomic.Bool | |
| 82 | 160 | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 83 | 161 | ||
| 162 | + | // Browser creation defaults copied from the factory so native popup shells | |
| 163 | + | // can apply the same settings in OnBeforePopup. | |
| 164 | + | windowlessFrameRate int32 | |
| 165 | + | backgroundColor uint32 | |
| 166 | + | ||
| 84 | 167 | // Audio output factory and active stream. | |
| 85 | 168 | audioOutputFactory port.AudioOutputFactory | |
| 86 | 169 | audioStreamMu sync.Mutex | |
| @@ -94,9 +177,11 @@ type WebView struct { | |||
| 94 | 177 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 95 | 178 | // deferred until the GL area has a non-zero size. | |
| 96 | 179 | type pendingBrowserCreate struct { | |
| 97 | - | windowInfo *purecef.WindowInfo | |
| 98 | - | client purecef.Client | |
| 99 | - | settings *purecef.BrowserSettings | |
| 180 | + | windowInfo *purecef.WindowInfo | |
| 181 | + | client purecef.RawClient | |
| 182 | + | settings *purecef.BrowserSettings | |
| 183 | + | extraInfo purecef.DictionaryValue | |
| 184 | + | postTaskRetries int | |
| 100 | 185 | } | |
| 101 | 186 | ||
| 102 | 187 | type cefTaskFunc func() | |
| @@ -126,16 +211,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||
| 126 | 211 | actualURI := toActualInternalURL(uri) | |
| 127 | 212 | wv.mu.Lock() | |
| 128 | 213 | browser := wv.browser | |
| 214 | + | // Always remember the latest requested URI so a browser/main-frame race | |
| 215 | + | // cannot strand startup on about:blank. | |
| 216 | + | wv.pendingURI = actualURI | |
| 217 | + | wv.mu.Unlock() | |
| 129 | 218 | if browser == nil { | |
| 130 | - | // Browser not yet created — queue the URI for OnAfterCreated. | |
| 131 | - | wv.pendingURI = actualURI | |
| 132 | - | wv.mu.Unlock() | |
| 133 | 219 | return nil | |
| 134 | 220 | } | |
| 135 | - | wv.mu.Unlock() | |
| 136 | - | if frame := browser.GetMainFrame(); frame != nil { | |
| 137 | - | frame.LoadURL(actualURI) | |
| 138 | - | } | |
| 221 | + | wv.schedulePendingNavigationReplay(0) | |
| 139 | 222 | return nil | |
| 140 | 223 | } | |
| 141 | 224 | ||
| @@ -422,6 +505,408 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) { | |||
| 422 | 505 | wv.callbacks = cb | |
| 423 | 506 | } | |
| 424 | 507 | ||
| 508 | + | // SetOnReadyToShow implements port.PopupLifecycleCapable. | |
| 509 | + | func (wv *WebView) SetOnReadyToShow(fn func()) { | |
| 510 | + | if wv == nil { | |
| 511 | + | return | |
| 512 | + | } | |
| 513 | + | wv.mu.Lock() | |
| 514 | + | wv.popupReadyToShow = fn | |
| 515 | + | alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil | |
| 516 | + | wv.mu.Unlock() | |
| 517 | + | if alreadyReady { | |
| 518 | + | wv.fireReadyToShow() | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // SetOnClose implements port.PopupLifecycleCapable. | |
| 523 | + | func (wv *WebView) SetOnClose(fn func()) { | |
| 524 | + | wv.AddCloseCallback(fn) | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | // Show implements port.PopupLifecycleCapable. | |
| 528 | + | func (wv *WebView) Show() { | |
| 529 | + | if wv == nil || wv.destroyed.Load() { | |
| 530 | + | return | |
| 531 | + | } | |
| 532 | + | wv.mu.RLock() | |
| 533 | + | host := wv.host | |
| 534 | + | wv.mu.RUnlock() | |
| 535 | + | if host != nil { | |
| 536 | + | host.WasHidden(0) | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // PrimePopupNavigation implements port.PopupLifecycleCapable. | |
| 541 | + | func (wv *WebView) PrimePopupNavigation(uri string) { | |
| 542 | + | if wv == nil || wv.destroyed.Load() { | |
| 543 | + | return | |
| 544 | + | } | |
| 545 | + | actualURI := toActualInternalURL(strings.TrimSpace(uri)) | |
| 546 | + | if actualURI == "" { | |
| 547 | + | return | |
| 548 | + | } | |
| 549 | + | wv.mu.Lock() | |
| 550 | + | browser := wv.browser | |
| 551 | + | wv.pendingURI = actualURI | |
| 552 | + | wv.mu.Unlock() | |
| 553 | + | if browser != nil { | |
| 554 | + | wv.schedulePendingNavigationReplay(0) | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // AddCloseCallback implements port.OAuthCallbackCapable. | |
| 559 | + | func (wv *WebView) AddCloseCallback(fn func()) { | |
| 560 | + | if wv == nil || fn == nil { | |
| 561 | + | return | |
| 562 | + | } | |
| 563 | + | wv.mu.Lock() | |
| 564 | + | wv.closeCallbacks = append(wv.closeCallbacks, fn) | |
| 565 | + | wv.mu.Unlock() | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // AddNavigationCallback implements port.OAuthCallbackCapable. | |
| 569 | + | func (wv *WebView) AddNavigationCallback(fn func(uri string)) { | |
| 570 | + | if wv == nil || fn == nil { | |
| 571 | + | return | |
| 572 | + | } | |
| 573 | + | wv.mu.Lock() | |
| 574 | + | wv.navigationCallbacks = append(wv.navigationCallbacks, fn) | |
| 575 | + | wv.mu.Unlock() | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // Close implements port.OAuthCallbackCapable. | |
| 579 | + | func (wv *WebView) Close() { | |
| 580 | + | if wv == nil || wv.destroyed.Load() { | |
| 581 | + | return | |
| 582 | + | } | |
| 583 | + | wv.mu.RLock() | |
| 584 | + | host := wv.host | |
| 585 | + | wv.mu.RUnlock() | |
| 586 | + | if host != nil { | |
| 587 | + | host.CloseBrowser(1) | |
| 588 | + | return | |
| 589 | + | } | |
| 590 | + | wv.runCloseCallbacks() | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | func (wv *WebView) fireReadyToShow() { | |
| 594 | + | if wv == nil { | |
| 595 | + | return | |
| 596 | + | } | |
| 597 | + | wv.mu.Lock() | |
| 598 | + | if wv.popupReadyShown || wv.popupReadyToShow == nil { | |
| 599 | + | wv.mu.Unlock() | |
| 600 | + | return | |
| 601 | + | } | |
| 602 | + | fn := wv.popupReadyToShow | |
| 603 | + | wv.popupReadyShown = true | |
| 604 | + | wv.mu.Unlock() | |
| 605 | + | wv.runOnGTK(fn) | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | func (wv *WebView) markNativePopupCandidate(parent *WebView) { | |
| 609 | + | if wv == nil { | |
| 610 | + | return | |
| 611 | + | } | |
| 612 | + | wv.stopNativePopupFallbackTimer() | |
| 613 | + | wv.mu.Lock() | |
| 614 | + | wv.nativePopupCandidate = true | |
| 615 | + | wv.nativePopupParent = parent | |
| 616 | + | wv.nativePopupID = 0 | |
| 617 | + | wv.nativePopupFallbackStarted = false | |
| 618 | + | wv.mu.Unlock() | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func (wv *WebView) isNativePopupCandidate() bool { | |
| 622 | + | if wv == nil { | |
| 623 | + | return false | |
| 624 | + | } | |
| 625 | + | wv.mu.RLock() | |
| 626 | + | defer wv.mu.RUnlock() | |
| 627 | + | return wv.nativePopupCandidate | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | func popupParentWindowHandle(parent *WebView) uintptr { | |
| 631 | + | if parent == nil { | |
| 632 | + | return 0 | |
| 633 | + | } | |
| 634 | + | parent.mu.RLock() | |
| 635 | + | host := parent.host | |
| 636 | + | parent.mu.RUnlock() | |
| 637 | + | if host == nil { | |
| 638 | + | return 0 | |
| 639 | + | } | |
| 640 | + | if handle := host.GetWindowHandle(); handle != 0 { | |
| 641 | + | return handle | |
| 642 | + | } | |
| 643 | + | return host.GetOpenerWindowHandle() | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | func (wv *WebView) discardNativePopupCandidate() { | |
| 647 | + | if wv == nil { | |
| 648 | + | return | |
| 649 | + | } | |
| 650 | + | wv.mu.Lock() | |
| 651 | + | wv.nativePopupCandidate = false | |
| 652 | + | wv.nativePopupParent = nil | |
| 653 | + | wv.nativePopupID = 0 | |
| 654 | + | wv.mu.Unlock() | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | func (wv *WebView) awaitsNativePopupAttachment() bool { | |
| 658 | + | if wv == nil { | |
| 659 | + | return false | |
| 660 | + | } | |
| 661 | + | wv.mu.RLock() | |
| 662 | + | defer wv.mu.RUnlock() | |
| 663 | + | return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0) | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer { | |
| 667 | + | if wv != nil && wv.nativePopupFallbackSchedule != nil { | |
| 668 | + | return wv.nativePopupFallbackSchedule | |
| 669 | + | } | |
| 670 | + | return func(delay time.Duration, fn func()) stoppableTimer { | |
| 671 | + | return time.AfterFunc(delay, fn) | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | func (wv *WebView) stopNativePopupFallbackTimer() { | |
| 676 | + | if wv == nil { | |
| 677 | + | return | |
| 678 | + | } | |
| 679 | + | wv.mu.Lock() | |
| 680 | + | timer := wv.nativePopupFallbackTimer | |
| 681 | + | wv.nativePopupFallbackTimer = nil | |
| 682 | + | wv.mu.Unlock() | |
| 683 | + | if timer != nil { | |
| 684 | + | timer.Stop() | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func (wv *WebView) scheduleNativePopupFallback(delay time.Duration, fn func()) { | |
| 689 | + | if wv == nil || fn == nil || wv.destroyed.Load() { | |
| 690 | + | return | |
| 691 | + | } | |
| 692 | + | wv.stopNativePopupFallbackTimer() | |
| 693 | + | var timer stoppableTimer | |
| 694 | + | timer = wv.nativePopupFallbackScheduler()(delay, func() { | |
| 695 | + | wv.mu.Lock() | |
| 696 | + | if wv.destroyed.Load() || wv.nativePopupFallbackTimer != timer { | |
| 697 | + | wv.mu.Unlock() | |
| 698 | + | return | |
| 699 | + | } | |
| 700 | + | wv.nativePopupFallbackTimer = nil | |
| 701 | + | wv.mu.Unlock() | |
| 702 | + | fn() | |
| 703 | + | }) | |
| 704 | + | wv.mu.Lock() | |
| 705 | + | if wv.destroyed.Load() { | |
| 706 | + | wv.mu.Unlock() | |
| 707 | + | if timer != nil { | |
| 708 | + | timer.Stop() | |
| 709 | + | } | |
| 710 | + | return | |
| 711 | + | } | |
| 712 | + | wv.nativePopupFallbackTimer = timer | |
| 713 | + | wv.mu.Unlock() | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | func (wv *WebView) preparePopupShellDirectBrowserCreation() bool { | |
| 717 | + | if wv == nil { | |
| 718 | + | return false | |
| 719 | + | } | |
| 720 | + | wv.stopNativePopupFallbackTimer() | |
| 721 | + | wv.mu.Lock() | |
| 722 | + | if wv.destroyed.Load() || wv.browser != nil || wv.nativePopupFallbackStarted || wv.pendingCreate == nil { | |
| 723 | + | wv.mu.Unlock() | |
| 724 | + | return false | |
| 725 | + | } | |
| 726 | + | parent := wv.nativePopupParent | |
| 727 | + | popupID := wv.nativePopupID | |
| 728 | + | wv.nativePopupFallbackStarted = true | |
| 729 | + | wv.nativePopupCandidate = false | |
| 730 | + | wv.nativePopupParent = nil | |
| 731 | + | wv.nativePopupID = 0 | |
| 732 | + | if !wv.popupNoJavaScriptAccess && parent != nil && !parent.destroyed.Load() { | |
| 733 | + | wv.popupOpenerBridgeParent = parent | |
| 734 | + | wv.popupOpenerBridgeParentURI = parent.URI() | |
| 735 | + | } else { | |
| 736 | + | wv.popupOpenerBridgeParent = nil | |
| 737 | + | wv.popupOpenerBridgeParentURI = "" | |
| 738 | + | } | |
| 739 | + | wv.syncPopupOpenerBridgeExtraInfoLocked() | |
| 740 | + | wv.mu.Unlock() | |
| 741 | + | if parent != nil && popupID != 0 { | |
| 742 | + | parent.clearPendingNativePopup(popupID, wv) | |
| 743 | + | } | |
| 744 | + | return true | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | func (wv *WebView) startNativePopupFallback() bool { | |
| 748 | + | if wv == nil { | |
| 749 | + | return false | |
| 750 | + | } | |
| 751 | + | wv.mu.RLock() | |
| 752 | + | destroyed := wv.destroyed.Load() | |
| 753 | + | alreadyStarted := wv.nativePopupFallbackStarted | |
| 754 | + | wv.mu.RUnlock() | |
| 755 | + | if destroyed || alreadyStarted { | |
| 756 | + | return false | |
| 757 | + | } | |
| 758 | + | return wv.preparePopupShellDirectBrowserCreation() | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | func (wv *WebView) trackPendingNativePopup(popupID int32, popup *WebView) { | |
| 762 | + | if wv == nil || popupID == 0 || popup == nil { | |
| 763 | + | return | |
| 764 | + | } | |
| 765 | + | wv.mu.Lock() | |
| 766 | + | if wv.pendingNativePopups == nil { | |
| 767 | + | wv.pendingNativePopups = make(map[int32]*WebView) | |
| 768 | + | } | |
| 769 | + | wv.pendingNativePopups[popupID] = popup | |
| 770 | + | wv.mu.Unlock() | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | func (wv *WebView) takePendingNativePopup(popupID int32) *WebView { | |
| 774 | + | if wv == nil || popupID == 0 { | |
| 775 | + | return nil | |
| 776 | + | } | |
| 777 | + | wv.mu.Lock() | |
| 778 | + | defer wv.mu.Unlock() | |
| 779 | + | popup := wv.pendingNativePopups[popupID] | |
| 780 | + | delete(wv.pendingNativePopups, popupID) | |
| 781 | + | return popup | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | func (wv *WebView) clearPendingNativePopup(popupID int32, popup *WebView) { | |
| 785 | + | if wv == nil || popupID == 0 || popup == nil { | |
| 786 | + | return | |
| 787 | + | } | |
| 788 | + | wv.mu.Lock() | |
| 789 | + | defer wv.mu.Unlock() | |
| 790 | + | if current := wv.pendingNativePopups[popupID]; current == popup { | |
| 791 | + | delete(wv.pendingNativePopups, popupID) | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | func (wv *WebView) prepareNativePopup( | |
| 796 | + | popupID int32, | |
| 797 | + | targetURL string, | |
| 798 | + | windowInfo *purecef.WindowInfo, | |
| 799 | + | clientSlot *purecef.RawClientWriteSlot, | |
| 800 | + | settings *purecef.BrowserSettings, | |
| 801 | + | ) bool { | |
| 802 | + | if wv == nil || wv.destroyed.Load() || popupID == 0 || windowInfo == nil || clientSlot == nil { | |
| 803 | + | return false | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | prep, ok := wv.nativePopupPreparationSnapshot() | |
| 807 | + | if !ok { | |
| 808 | + | return false | |
| 809 | + | } | |
| 810 | + | parentWindowHandle := popupParentWindowHandle(prep.parent) | |
| 811 | + | if parentWindowHandle == 0 { | |
| 812 | + | return false | |
| 813 | + | } | |
| 814 | + | client, ok := wv.activateNativePopup(popupID, targetURL) | |
| 815 | + | if !ok { | |
| 816 | + | return false | |
| 817 | + | } | |
| 818 | + | if prep.parent != nil { | |
| 819 | + | prep.parent.trackPendingNativePopup(popupID, wv) | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | configureNativePopupWindow(windowInfo, settings, parentWindowHandle, prep.frameRate, prep.backgroundColor) | |
| 823 | + | clientSlot.Set(client) | |
| 824 | + | return true | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, bool) { | |
| 828 | + | if wv == nil { | |
| 829 | + | return nativePopupPreparation{}, false | |
| 830 | + | } | |
| 831 | + | wv.mu.RLock() | |
| 832 | + | defer wv.mu.RUnlock() | |
| 833 | + | if !wv.canPrepareNativePopupLocked() { | |
| 834 | + | return nativePopupPreparation{}, false | |
| 835 | + | } | |
| 836 | + | return nativePopupPreparation{ | |
| 837 | + | parent: wv.nativePopupParent, | |
| 838 | + | frameRate: wv.windowlessFrameRate, | |
| 839 | + | backgroundColor: wv.backgroundColor, | |
| 840 | + | }, true | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | func (wv *WebView) activateNativePopup(popupID int32, targetURL string) (purecef.RawClient, bool) { | |
| 844 | + | if wv == nil { | |
| 845 | + | return nil, false | |
| 846 | + | } | |
| 847 | + | wv.mu.Lock() | |
| 848 | + | defer wv.mu.Unlock() | |
| 849 | + | if !wv.canPrepareNativePopupLocked() { | |
| 850 | + | return nil, false | |
| 851 | + | } | |
| 852 | + | wv.nativePopupCandidate = false | |
| 853 | + | wv.nativePopupID = popupID | |
| 854 | + | wv.nativePopupFallbackStarted = false | |
| 855 | + | if strings.TrimSpace(wv.pendingURI) == "" { | |
| 856 | + | wv.pendingURI = toActualInternalURL(targetURL) | |
| 857 | + | } | |
| 858 | + | if strings.TrimSpace(wv.pendingURI) != "" { | |
| 859 | + | wv.uri = toConceptualInternalURL(wv.pendingURI) | |
| 860 | + | } else { | |
| 861 | + | wv.uri = toConceptualInternalURL(targetURL) | |
| 862 | + | } | |
| 863 | + | wv.isLoading = true | |
| 864 | + | return wv.client, true | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | func (wv *WebView) canPrepareNativePopupLocked() bool { | |
| 868 | + | return wv.nativePopupCandidate && wv.client != nil && !wv.nativePopupFallbackStarted && wv.browser == nil | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | func configureNativePopupWindow( | |
| 872 | + | windowInfo *purecef.WindowInfo, | |
| 873 | + | settings *purecef.BrowserSettings, | |
| 874 | + | parentWindowHandle uintptr, | |
| 875 | + | frameRate int32, | |
| 876 | + | backgroundColor uint32, | |
| 877 | + | ) { | |
| 878 | + | purecef.SetAsWindowless(windowInfo, purecef.WindowHandle(parentWindowHandle), false) | |
| 879 | + | if externalBeginFrameEnabled() { | |
| 880 | + | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 881 | + | } | |
| 882 | + | if settings == nil { | |
| 883 | + | return | |
| 884 | + | } | |
| 885 | + | if frameRate > 0 { | |
| 886 | + | settings.WindowlessFrameRate = frameRate | |
| 887 | + | } | |
| 888 | + | settings.LocalStorage = 1 | |
| 889 | + | if backgroundColor != 0 { | |
| 890 | + | settings.BackgroundColor = backgroundColor | |
| 891 | + | } | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | type nativePopupPreparation struct { | |
| 895 | + | parent *WebView | |
| 896 | + | frameRate int32 | |
| 897 | + | backgroundColor uint32 | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | func (wv *WebView) handleNativePopupAborted() { | |
| 901 | + | if wv == nil { | |
| 902 | + | return | |
| 903 | + | } | |
| 904 | + | wv.mu.Lock() | |
| 905 | + | wv.nativePopupCandidate = false | |
| 906 | + | wv.nativePopupID = 0 | |
| 907 | + | wv.mu.Unlock() | |
| 908 | + | } | |
| 909 | + | ||
| 425 | 910 | // --------------------------------------------------------------------------- | |
| 426 | 911 | // JavaScript / Appearance | |
| 427 | 912 | // --------------------------------------------------------------------------- | |
| @@ -445,6 +930,40 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||
| 445 | 930 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | |
| 446 | 931 | const colorScale = 255 | |
| 447 | 932 | ||
| 933 | + | var bridgeNonceRandom = rand.Read | |
| 934 | + | ||
| 935 | + | func newBridgeNonce() string { | |
| 936 | + | buf := make([]byte, 16) | |
| 937 | + | if _, err := bridgeNonceRandom(buf); err != nil { | |
| 938 | + | return "" | |
| 939 | + | } | |
| 940 | + | return hex.EncodeToString(buf) | |
| 941 | + | } | |
| 942 | + | ||
| 943 | + | func (wv *WebView) rotateBridgeNonce() string { | |
| 944 | + | nonce := newBridgeNonce() | |
| 945 | + | if nonce == "" { | |
| 946 | + | return "" | |
| 947 | + | } | |
| 948 | + | wv.mu.Lock() | |
| 949 | + | wv.bridgeNonce = nonce | |
| 950 | + | wv.mu.Unlock() | |
| 951 | + | return nonce | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | func (wv *WebView) ensureBridgeNonce() string { | |
| 955 | + | if wv == nil { | |
| 956 | + | return "" | |
| 957 | + | } | |
| 958 | + | wv.mu.RLock() | |
| 959 | + | nonce := wv.bridgeNonce | |
| 960 | + | wv.mu.RUnlock() | |
| 961 | + | if nonce != "" { | |
| 962 | + | return nonce | |
| 963 | + | } | |
| 964 | + | return wv.rotateBridgeNonce() | |
| 965 | + | } | |
| 966 | + | ||
| 448 | 967 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | |
| 449 | 968 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | |
| 450 | 969 | script := fmt.Sprintf( | |
| @@ -472,6 +991,27 @@ func (wv *WebView) Destroy() { | |||
| 472 | 991 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 473 | 992 | return | |
| 474 | 993 | } | |
| 994 | + | wv.syntheticPopupMu.Lock() | |
| 995 | + | wv.syntheticPopups = nil | |
| 996 | + | wv.syntheticPopupMu.Unlock() | |
| 997 | + | wv.mu.Lock() | |
| 998 | + | wv.navigationCallbacks = nil | |
| 999 | + | wv.openerMessageCallbacks = nil | |
| 1000 | + | wv.openerNavigationCallbacks = nil | |
| 1001 | + | wv.popupReadyToShow = nil | |
| 1002 | + | wv.pendingNativePopups = nil | |
| 1003 | + | wv.nativePopupParent = nil | |
| 1004 | + | wv.nativePopupCandidate = false | |
| 1005 | + | wv.nativePopupID = 0 | |
| 1006 | + | wv.nativePopupFallbackStarted = false | |
| 1007 | + | wv.popupOpenerBridgeParent = nil | |
| 1008 | + | wv.popupOpenerBridgeParentURI = "" | |
| 1009 | + | wv.mu.Unlock() | |
| 1010 | + | wv.stopNativePopupFallbackTimer() | |
| 1011 | + | wv.cancelSelectionDebounce() | |
| 1012 | + | if wv.resizeReconciler != nil { | |
| 1013 | + | wv.resizeReconciler.stop() | |
| 1014 | + | } | |
| 475 | 1015 | wv.closeAudioStream() | |
| 476 | 1016 | wv.scheduleStopBeginFrameLoop() | |
| 477 | 1017 | wv.mu.RLock() | |
| @@ -479,6 +1019,8 @@ func (wv *WebView) Destroy() { | |||
| 479 | 1019 | wv.mu.RUnlock() | |
| 480 | 1020 | if host != nil { | |
| 481 | 1021 | host.CloseBrowser(1) | |
| 1022 | + | } else { | |
| 1023 | + | wv.runCloseCallbacks() | |
| 482 | 1024 | } | |
| 483 | 1025 | if wv.pipeline != nil { | |
| 484 | 1026 | wv.pipeline.destroy() | |
| @@ -503,16 +1045,29 @@ func (wv *WebView) NativeWidget() uintptr { | |||
| 503 | 1045 | ||
| 504 | 1046 | func (wv *WebView) updateURI(uri string) { | |
| 505 | 1047 | uri = toConceptualInternalURL(uri) | |
| 1048 | + | now := time.Now() | |
| 506 | 1049 | wv.mu.Lock() | |
| 507 | 1050 | wv.uri = uri | |
| 1051 | + | wv.loadDiagLastAddressAt = now | |
| 508 | 1052 | cb := wv.callbacks | |
| 1053 | + | pendingMatched := pendingURIEquivalent(wv.pendingURI, uri) | |
| 1054 | + | if pendingMatched { | |
| 1055 | + | wv.pendingURI = "" | |
| 1056 | + | } | |
| 509 | 1057 | wv.mu.Unlock() | |
| 510 | 1058 | ||
| 1059 | + | if pendingMatched && wv.ctx != nil { | |
| 1060 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1061 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1062 | + | Msg("cef: pending navigation acknowledged by address change") | |
| 1063 | + | } | |
| 1064 | + | ||
| 511 | 1065 | if cb != nil && cb.OnURIChanged != nil { | |
| 512 | 1066 | wv.runOnGTK(func() { | |
| 513 | 1067 | cb.OnURIChanged(uri) | |
| 514 | 1068 | }) | |
| 515 | 1069 | } | |
| 1070 | + | wv.runNavigationCallbacks(uri) | |
| 516 | 1071 | } | |
| 517 | 1072 | ||
| 518 | 1073 | func (wv *WebView) updateTitle(title string) { | |
| @@ -529,8 +1084,10 @@ func (wv *WebView) updateTitle(title string) { | |||
| 529 | 1084 | } | |
| 530 | 1085 | ||
| 531 | 1086 | func (wv *WebView) updateProgress(progress float64) { | |
| 1087 | + | now := time.Now() | |
| 532 | 1088 | wv.mu.Lock() | |
| 533 | 1089 | wv.progress = progress | |
| 1090 | + | wv.loadDiagLastProgressAt = now | |
| 534 | 1091 | cb := wv.callbacks | |
| 535 | 1092 | wv.mu.Unlock() | |
| 536 | 1093 | ||
| @@ -542,11 +1099,26 @@ func (wv *WebView) updateProgress(progress float64) { | |||
| 542 | 1099 | } | |
| 543 | 1100 | ||
| 544 | 1101 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | |
| 1102 | + | now := time.Now() | |
| 545 | 1103 | wv.mu.Lock() | |
| 1104 | + | wasLoading := wv.isLoading | |
| 546 | 1105 | wv.isLoading = loading | |
| 547 | 1106 | wv.canGoBack = back | |
| 548 | 1107 | wv.canGoFwd = fwd | |
| 1108 | + | wv.loadDiagLastLoadStateAt = now | |
| 1109 | + | var loadDiagSeq uint64 | |
| 1110 | + | if loading && !wasLoading { | |
| 1111 | + | wv.loadDiagSeq++ | |
| 1112 | + | wv.loadDiagStartedAt = now | |
| 1113 | + | wv.loadDiagLastProgressAt = time.Time{} | |
| 1114 | + | wv.loadDiagLastAddressAt = time.Time{} | |
| 1115 | + | loadDiagSeq = wv.loadDiagSeq | |
| 1116 | + | } | |
| 549 | 1117 | wv.mu.Unlock() | |
| 1118 | + | ||
| 1119 | + | if loadDiagSeq != 0 { | |
| 1120 | + | wv.scheduleLoadWatchdogs(loadDiagSeq) | |
| 1121 | + | } | |
| 550 | 1122 | } | |
| 551 | 1123 | ||
| 552 | 1124 | func (wv *WebView) setAudioPlaying(playing bool) { | |
| @@ -561,6 +1133,320 @@ func (wv *WebView) setAudioPlaying(playing bool) { | |||
| 561 | 1133 | } | |
| 562 | 1134 | } | |
| 563 | 1135 | ||
| 1136 | + | func (wv *WebView) setSelectedText(text string) (string, bool) { | |
| 1137 | + | wv.mu.Lock() | |
| 1138 | + | previous := wv.selectedText | |
| 1139 | + | changed := previous != text | |
| 1140 | + | wv.selectedText = text | |
| 1141 | + | wv.mu.Unlock() | |
| 1142 | + | return previous, changed | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | func (wv *WebView) setEditableFocus(editable bool) { | |
| 1146 | + | if wv == nil { | |
| 1147 | + | return | |
| 1148 | + | } | |
| 1149 | + | wv.mu.Lock() | |
| 1150 | + | previous := wv.focusedEditable | |
| 1151 | + | wv.focusedEditable = editable | |
| 1152 | + | if editable { | |
| 1153 | + | wv.selectionDebounceSeq++ | |
| 1154 | + | timer := wv.selectionDebounceTimer | |
| 1155 | + | wv.selectionDebounceTimer = nil | |
| 1156 | + | wv.mu.Unlock() | |
| 1157 | + | if timer != nil { | |
| 1158 | + | timer.Stop() | |
| 1159 | + | } | |
| 1160 | + | if previous != editable && wv.ctx != nil { | |
| 1161 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |
| 1162 | + | } | |
| 1163 | + | return | |
| 1164 | + | } | |
| 1165 | + | wv.mu.Unlock() | |
| 1166 | + | if previous != editable && wv.ctx != nil { | |
| 1167 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |
| 1168 | + | } | |
| 1169 | + | } | |
| 1170 | + | ||
| 1171 | + | func (wv *WebView) cancelSelectionDebounce() { | |
| 1172 | + | if wv == nil { | |
| 1173 | + | return | |
| 1174 | + | } | |
| 1175 | + | wv.mu.Lock() | |
| 1176 | + | wv.selectionDebounceSeq++ | |
| 1177 | + | timer := wv.selectionDebounceTimer | |
| 1178 | + | wv.selectionDebounceTimer = nil | |
| 1179 | + | wv.mu.Unlock() | |
| 1180 | + | if timer != nil { | |
| 1181 | + | timer.Stop() | |
| 1182 | + | } | |
| 1183 | + | } | |
| 1184 | + | ||
| 1185 | + | func (wv *WebView) selectionDebounceInterval() time.Duration { | |
| 1186 | + | if wv == nil || wv.selectionDebounceDelay == nil { | |
| 1187 | + | return clipboardSelectionDebounceDelay | |
| 1188 | + | } | |
| 1189 | + | return *wv.selectionDebounceDelay | |
| 1190 | + | } | |
| 1191 | + | ||
| 1192 | + | func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer { | |
| 1193 | + | if wv != nil && wv.selectionDebounceSchedule != nil { | |
| 1194 | + | return wv.selectionDebounceSchedule | |
| 1195 | + | } | |
| 1196 | + | return func(delay time.Duration, fn func()) stoppableTimer { | |
| 1197 | + | return time.AfterFunc(delay, fn) | |
| 1198 | + | } | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | func (wv *WebView) scheduleSelectionUpdate(text string) { | |
| 1202 | + | if wv == nil || wv.destroyed.Load() { | |
| 1203 | + | return | |
| 1204 | + | } | |
| 1205 | + | wv.mu.Lock() | |
| 1206 | + | if wv.destroyed.Load() || wv.engine == nil { | |
| 1207 | + | wv.mu.Unlock() | |
| 1208 | + | return | |
| 1209 | + | } | |
| 1210 | + | if wv.focusedEditable { | |
| 1211 | + | wv.mu.Unlock() | |
| 1212 | + | if wv.ctx != nil { | |
| 1213 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1214 | + | Int("text_len", len(text)). | |
| 1215 | + | Msg("cef: selection auto-copy skipped while editable focused") | |
| 1216 | + | } | |
| 1217 | + | return | |
| 1218 | + | } | |
| 1219 | + | wv.selectionDebounceSeq++ | |
| 1220 | + | seq := wv.selectionDebounceSeq | |
| 1221 | + | if timer := wv.selectionDebounceTimer; timer != nil { | |
| 1222 | + | timer.Stop() | |
| 1223 | + | } | |
| 1224 | + | delay := wv.selectionDebounceInterval() | |
| 1225 | + | if delay <= 0 { | |
| 1226 | + | wv.selectionDebounceTimer = nil | |
| 1227 | + | wv.mu.Unlock() | |
| 1228 | + | wv.flushSelectionUpdate(seq, text) | |
| 1229 | + | return | |
| 1230 | + | } | |
| 1231 | + | scheduler := wv.selectionDebounceScheduler() | |
| 1232 | + | wv.selectionDebounceTimer = scheduler(delay, func() { | |
| 1233 | + | wv.flushSelectionUpdate(seq, text) | |
| 1234 | + | }) | |
| 1235 | + | wv.mu.Unlock() | |
| 1236 | + | } | |
| 1237 | + | ||
| 1238 | + | func (wv *WebView) flushSelectionUpdate(seq uint64, text string) { | |
| 1239 | + | if wv == nil || wv.destroyed.Load() { | |
| 1240 | + | return | |
| 1241 | + | } | |
| 1242 | + | wv.mu.Lock() | |
| 1243 | + | if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq { | |
| 1244 | + | wv.mu.Unlock() | |
| 1245 | + | return | |
| 1246 | + | } | |
| 1247 | + | engine := wv.engine | |
| 1248 | + | viewID := wv.id | |
| 1249 | + | wv.mu.Unlock() | |
| 1250 | + | if engine != nil { | |
| 1251 | + | if wv.ctx != nil { | |
| 1252 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1253 | + | Int("text_len", len(text)). | |
| 1254 | + | Msg("cef: debounced selection update flushed") | |
| 1255 | + | } | |
| 1256 | + | engine.handleClipboardSelectionUpdate(viewID, text) | |
| 1257 | + | } | |
| 1258 | + | } | |
| 1259 | + | ||
| 1260 | + | func (wv *WebView) selectedTextSnapshot() string { | |
| 1261 | + | wv.mu.RLock() | |
| 1262 | + | defer wv.mu.RUnlock() | |
| 1263 | + | return wv.selectedText | |
| 1264 | + | } | |
| 1265 | + | ||
| 1266 | + | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |
| 1267 | + | for _, delay := range cefLoadWatchdogDelays { | |
| 1268 | + | d := delay | |
| 1269 | + | time.AfterFunc(d, func() { | |
| 1270 | + | wv.logLoadWatchdog(loadSeq, d) | |
| 1271 | + | }) | |
| 1272 | + | } | |
| 1273 | + | } | |
| 1274 | + | ||
| 1275 | + | func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |
| 1276 | + | if wv == nil || wv.destroyed.Load() || wv.ctx == nil { | |
| 1277 | + | return | |
| 1278 | + | } | |
| 1279 | + | ||
| 1280 | + | wv.mu.RLock() | |
| 1281 | + | currentSeq := wv.loadDiagSeq | |
| 1282 | + | loading := wv.isLoading | |
| 1283 | + | uri := wv.uri | |
| 1284 | + | title := wv.title | |
| 1285 | + | progress := wv.progress | |
| 1286 | + | pendingURI := wv.pendingURI | |
| 1287 | + | startedAt := wv.loadDiagStartedAt | |
| 1288 | + | lastProgressAt := wv.loadDiagLastProgressAt | |
| 1289 | + | lastAddressAt := wv.loadDiagLastAddressAt | |
| 1290 | + | lastLoadStateAt := wv.loadDiagLastLoadStateAt | |
| 1291 | + | wv.mu.RUnlock() | |
| 1292 | + | ||
| 1293 | + | if loadSeq != currentSeq || !loading { | |
| 1294 | + | return | |
| 1295 | + | } | |
| 1296 | + | ||
| 1297 | + | snap := renderPipelineSnapshot{} | |
| 1298 | + | if wv.pipeline != nil { | |
| 1299 | + | snap = wv.pipeline.diagnosticSnapshot() | |
| 1300 | + | } | |
| 1301 | + | ||
| 1302 | + | now := time.Now() | |
| 1303 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1304 | + | Uint64("load_seq", loadSeq). | |
| 1305 | + | Int64("watch_delay_ms", delay.Milliseconds()). | |
| 1306 | + | Int64("since_start_ms", sinceTimestampMs(startedAt, now)). | |
| 1307 | + | Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)). | |
| 1308 | + | Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)). | |
| 1309 | + | Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)). | |
| 1310 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1311 | + | Str("title", title). | |
| 1312 | + | Float64("progress", progress). | |
| 1313 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 1314 | + | Int32("surface_width", snap.Width). | |
| 1315 | + | Int32("surface_height", snap.Height). | |
| 1316 | + | Int32("surface_scale", snap.Scale). | |
| 1317 | + | Bool("gl_ready", snap.GLReady). | |
| 1318 | + | Bool("needs_upload", snap.NeedsUpload). | |
| 1319 | + | Bool("size_changed", snap.SizeChanged). | |
| 1320 | + | Bool("force_full_upload", snap.ForceFullUpload). | |
| 1321 | + | Uint64("view_rect_seq", snap.ViewRectSeq). | |
| 1322 | + | Uint64("screen_info_seq", snap.ScreenInfoSeq). | |
| 1323 | + | Uint64("paint_seq", snap.PaintSeq). | |
| 1324 | + | Uint64("resize_seq", snap.ResizeSeq). | |
| 1325 | + | Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq). | |
| 1326 | + | Uint64("gl_render_seq", snap.GLRenderSeq). | |
| 1327 | + | Uint64("paint_total", snap.PaintCount). | |
| 1328 | + | Uint64("queue_total", snap.QueueRenderCount). | |
| 1329 | + | Uint64("render_total", snap.RenderCount). | |
| 1330 | + | Uint64("upload_total", snap.UploadCount). | |
| 1331 | + | Uint64("full_upload_total", snap.FullUploadCount). | |
| 1332 | + | Msg("cef: loading watchdog") | |
| 1333 | + | } | |
| 1334 | + | ||
| 1335 | + | func (wv *WebView) pendingNavigationURI() string { | |
| 1336 | + | if wv == nil { | |
| 1337 | + | return "" | |
| 1338 | + | } | |
| 1339 | + | wv.mu.RLock() | |
| 1340 | + | defer wv.mu.RUnlock() | |
| 1341 | + | return wv.pendingURI | |
| 1342 | + | } | |
| 1343 | + | ||
| 1344 | + | func (wv *WebView) schedulePendingNavigationReplay(attempt int) { | |
| 1345 | + | if wv == nil || wv.destroyed.Load() { | |
| 1346 | + | return | |
| 1347 | + | } | |
| 1348 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 1349 | + | wv.replayPendingNavigation(attempt) | |
| 1350 | + | })) | |
| 1351 | + | if task == nil { | |
| 1352 | + | return | |
| 1353 | + | } | |
| 1354 | + | var result int32 | |
| 1355 | + | if attempt <= 0 { | |
| 1356 | + | result = cefPostTask(purecef.ThreadIDTidUi, task) | |
| 1357 | + | } else { | |
| 1358 | + | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | |
| 1359 | + | } | |
| 1360 | + | if result == 1 { | |
| 1361 | + | return | |
| 1362 | + | } | |
| 1363 | + | if wv.ctx != nil { | |
| 1364 | + | log := logging.FromContext(wv.ctx).Warn(). | |
| 1365 | + | Int("attempt", attempt). | |
| 1366 | + | Int32("result", result) | |
| 1367 | + | if attempt >= pendingNavigationMaxRetries { | |
| 1368 | + | log.Msg("cef: failed to schedule pending navigation replay; retries exhausted") | |
| 1369 | + | } else { | |
| 1370 | + | log.Msg("cef: failed to schedule pending navigation replay; retrying") | |
| 1371 | + | } | |
| 1372 | + | } | |
| 1373 | + | if attempt >= pendingNavigationMaxRetries { | |
| 1374 | + | return | |
| 1375 | + | } | |
| 1376 | + | cefScheduleAfter(pendingNavigationRetryDelay, func() { | |
| 1377 | + | wv.schedulePendingNavigationReplay(attempt + 1) | |
| 1378 | + | }) | |
| 1379 | + | } | |
| 1380 | + | ||
| 1381 | + | func (wv *WebView) replayPendingNavigation(attempt int) { | |
| 1382 | + | if wv == nil || wv.destroyed.Load() { | |
| 1383 | + | return | |
| 1384 | + | } | |
| 1385 | + | wv.mu.RLock() | |
| 1386 | + | uri := wv.pendingURI | |
| 1387 | + | browser := wv.browser | |
| 1388 | + | wv.mu.RUnlock() | |
| 1389 | + | if uri == "" || browser == nil { | |
| 1390 | + | return | |
| 1391 | + | } | |
| 1392 | + | frame := browser.GetMainFrame() | |
| 1393 | + | if frame == nil { | |
| 1394 | + | if attempt >= pendingNavigationMaxRetries { | |
| 1395 | + | if wv.ctx != nil { | |
| 1396 | + | logging.FromContext(wv.ctx).Warn(). | |
| 1397 | + | Int("attempt", attempt). | |
| 1398 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1399 | + | Msg("cef: pending navigation replay exhausted without main frame") | |
| 1400 | + | } | |
| 1401 | + | return | |
| 1402 | + | } | |
| 1403 | + | if wv.ctx != nil { | |
| 1404 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1405 | + | Int("attempt", attempt). | |
| 1406 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1407 | + | Msg("cef: pending navigation replay waiting for main frame") | |
| 1408 | + | } | |
| 1409 | + | wv.schedulePendingNavigationReplay(attempt + 1) | |
| 1410 | + | return | |
| 1411 | + | } | |
| 1412 | + | currentURL := frame.GetURL() | |
| 1413 | + | if pendingURIEquivalent(currentURL, uri) { | |
| 1414 | + | wv.mu.Lock() | |
| 1415 | + | if pendingURIEquivalent(wv.pendingURI, uri) { | |
| 1416 | + | wv.pendingURI = "" | |
| 1417 | + | } | |
| 1418 | + | wv.mu.Unlock() | |
| 1419 | + | if wv.ctx != nil { | |
| 1420 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1421 | + | Int("attempt", attempt). | |
| 1422 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1423 | + | Msg("cef: pending navigation already active") | |
| 1424 | + | } | |
| 1425 | + | return | |
| 1426 | + | } | |
| 1427 | + | frame.LoadURL(uri) | |
| 1428 | + | if wv.ctx != nil { | |
| 1429 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1430 | + | Int("attempt", attempt). | |
| 1431 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 1432 | + | Msg("cef: replayed pending navigation") | |
| 1433 | + | } | |
| 1434 | + | } | |
| 1435 | + | ||
| 1436 | + | func pendingURIEquivalent(a, b string) bool { | |
| 1437 | + | if a == "" || b == "" { | |
| 1438 | + | return false | |
| 1439 | + | } | |
| 1440 | + | return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b)) | |
| 1441 | + | } | |
| 1442 | + | ||
| 1443 | + | func sinceTimestampMs(ts, now time.Time) int64 { | |
| 1444 | + | if ts.IsZero() { | |
| 1445 | + | return -1 | |
| 1446 | + | } | |
| 1447 | + | return now.Sub(ts).Milliseconds() | |
| 1448 | + | } | |
| 1449 | + | ||
| 564 | 1450 | func (wv *WebView) updateHoverURI(uri string) { | |
| 565 | 1451 | wv.mu.Lock() | |
| 566 | 1452 | wv.lastHoverURI = uri | |
| @@ -708,6 +1594,70 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 708 | 1594 | glib.IdleAddOnce(cb, 0) | |
| 709 | 1595 | } | |
| 710 | 1596 | ||
| 1597 | + | func (wv *WebView) isOnGTKThread() bool { | |
| 1598 | + | mainContext := glib.MainContextDefault() | |
| 1599 | + | return mainContext != nil && mainContext.IsOwner() | |
| 1600 | + | } | |
| 1601 | + | ||
| 1602 | + | // runOnGTKSync executes fn on the GTK main context and waits for completion. | |
| 1603 | + | // Callers already running on the GTK thread must execute inline to avoid | |
| 1604 | + | // self-deadlocking while waiting for an IdleAddOnce callback. | |
| 1605 | + | func (wv *WebView) runOnGTKSync(fn func()) { | |
| 1606 | + | if fn == nil { | |
| 1607 | + | return | |
| 1608 | + | } | |
| 1609 | + | if wv == nil || wv.engine == nil || wv.isOnGTKThread() { | |
| 1610 | + | fn() | |
| 1611 | + | return | |
| 1612 | + | } | |
| 1613 | + | ||
| 1614 | + | done := make(chan struct{}) | |
| 1615 | + | wv.runOnGTK(func() { | |
| 1616 | + | defer close(done) | |
| 1617 | + | fn() | |
| 1618 | + | }) | |
| 1619 | + | <-done | |
| 1620 | + | } | |
| 1621 | + | ||
| 1622 | + | func (wv *WebView) runCloseCallbacks() { | |
| 1623 | + | if wv == nil { | |
| 1624 | + | return | |
| 1625 | + | } | |
| 1626 | + | wv.mu.Lock() | |
| 1627 | + | callbacks := append([]func(){}, wv.closeCallbacks...) | |
| 1628 | + | wv.closeCallbacks = nil | |
| 1629 | + | wv.mu.Unlock() | |
| 1630 | + | if len(callbacks) == 0 { | |
| 1631 | + | return | |
| 1632 | + | } | |
| 1633 | + | wv.runOnGTK(func() { | |
| 1634 | + | for _, fn := range callbacks { | |
| 1635 | + | if fn != nil { | |
| 1636 | + | fn() | |
| 1637 | + | } | |
| 1638 | + | } | |
| 1639 | + | }) | |
| 1640 | + | } | |
| 1641 | + | ||
| 1642 | + | func (wv *WebView) runNavigationCallbacks(uri string) { | |
| 1643 | + | if wv == nil { | |
| 1644 | + | return | |
| 1645 | + | } | |
| 1646 | + | wv.mu.RLock() | |
| 1647 | + | callbacks := append([]func(string){}, wv.navigationCallbacks...) | |
| 1648 | + | wv.mu.RUnlock() | |
| 1649 | + | if len(callbacks) == 0 { | |
| 1650 | + | return | |
| 1651 | + | } | |
| 1652 | + | wv.runOnGTK(func() { | |
| 1653 | + | for _, fn := range callbacks { | |
| 1654 | + | if fn != nil { | |
| 1655 | + | fn(uri) | |
| 1656 | + | } | |
| 1657 | + | } | |
| 1658 | + | }) | |
| 1659 | + | } | |
| 1660 | + | ||
| 711 | 1661 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |
| 712 | 1662 | wv.mu.Lock() | |
| 713 | 1663 | defer wv.mu.Unlock() | |
A internal/infrastructure/cef/webview_bridge_nonce_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 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 | + | } | |
| 58 | + | ||
| 59 | + | func TestWebViewEnsureBridgeNonce_ReusesExistingNonce(t *testing.T) { | |
| 60 | + | wv := &WebView{bridgeNonce: "existing-nonce"} | |
| 61 | + | require.Equal(t, "existing-nonce", wv.ensureBridgeNonce()) | |
| 62 | + | } | |
M internal/infrastructure/cef/webview_handlers.go +498 -79
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | ) | |
| @@ -15,28 +16,29 @@ import ( | |||
| 15 | 16 | const ( | |
| 16 | 17 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 17 | 18 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |
| 19 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| 21 | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 22 | 24 | // methods can return the same receiver, avoiding extra allocations. | |
| 23 | 25 | type handlerSet struct { | |
| 24 | - | wv *WebView | |
| 25 | - | enableContextMenuHandler bool | |
| 26 | - | transcodingHandler purecef.ResourceRequestHandler | |
| 26 | + | wv *WebView | |
| 27 | + | transcodingHandler purecef.ResourceRequestHandler | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | // Compile-time interface checks. | |
| 30 | 31 | var ( | |
| 31 | - | _ purecef.SafeClient = (*handlerSet)(nil) | |
| 32 | - | _ purecef.RenderHandler = (*handlerSet)(nil) | |
| 33 | - | _ purecef.DisplayHandler = (*handlerSet)(nil) | |
| 34 | - | _ purecef.LoadHandler = (*handlerSet)(nil) | |
| 35 | - | _ purecef.SafeLifeSpanHandler = (*handlerSet)(nil) | |
| 36 | - | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 37 | - | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 38 | - | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | |
| 39 | - | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 32 | + | _ purecef.Client = (*handlerSet)(nil) | |
| 33 | + | _ purecef.RenderHandler = (*handlerSet)(nil) | |
| 34 | + | _ purecef.DisplayHandler = (*handlerSet)(nil) | |
| 35 | + | _ purecef.LoadHandler = (*handlerSet)(nil) | |
| 36 | + | _ purecef.LifeSpanHandler = (*handlerSet)(nil) | |
| 37 | + | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 38 | + | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 39 | + | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | |
| 40 | + | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 41 | + | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 40 | 42 | ) | |
| 41 | 43 | ||
| 42 | 44 | // =========================================================================== | |
| @@ -50,16 +52,16 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||
| 50 | 52 | // audio backend (PipeWire/PulseAudio) is wired up. | |
| 51 | 53 | return h | |
| 52 | 54 | } | |
| 53 | - | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 54 | - | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | |
| 55 | - | if h.enableContextMenuHandler { | |
| 55 | + | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 56 | + | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | |
| 57 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 58 | + | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 59 | + | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | |
| 60 | + | if h.downloadHandler() != nil { | |
| 56 | 61 | return h | |
| 57 | 62 | } | |
| 58 | 63 | return nil | |
| 59 | 64 | } | |
| 60 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 61 | - | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 62 | - | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } | |
| 63 | 65 | func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil } | |
| 64 | 66 | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | |
| 65 | 67 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| @@ -67,14 +69,73 @@ func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return n | |||
| 67 | 69 | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | |
| 68 | 70 | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | |
| 69 | 71 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 70 | - | func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h } | |
| 72 | + | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| 71 | 73 | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| 72 | 74 | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |
| 73 | 75 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 74 | 76 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 75 | 77 | ||
| 76 | - | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | |
| 77 | - | return 0 | |
| 78 | + | func (h *handlerSet) OnProcessMessageReceived( | |
| 79 | + | browser purecef.Browser, | |
| 80 | + | _ purecef.Frame, | |
| 81 | + | _ purecef.ProcessID, | |
| 82 | + | message purecef.ProcessMessage, | |
| 83 | + | ) int32 { | |
| 84 | + | if h == nil || h.wv == nil || h.wv.engine == nil { | |
| 85 | + | return 0 | |
| 86 | + | } | |
| 87 | + | action, payload, ok := decodeRendererBridgeProcessMessage(message) | |
| 88 | + | if !ok { | |
| 89 | + | return 0 | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | log := logging.FromContext(h.wv.ctx) | |
| 93 | + | log.Debug(). | |
| 94 | + | Str("action", action). | |
| 95 | + | Int("payload_len", len(payload)). | |
| 96 | + | Msg("cef: renderer bridge message received") | |
| 97 | + | ||
| 98 | + | switch action { | |
| 99 | + | case rendererBridgeActionExplicitTextCopy: | |
| 100 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload)) | |
| 101 | + | if err != nil { | |
| 102 | + | log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload") | |
| 103 | + | return 1 | |
| 104 | + | } | |
| 105 | + | h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text) | |
| 106 | + | case rendererBridgeActionEditableFocusChanged: | |
| 107 | + | h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true")) | |
| 108 | + | case rendererBridgeActionFocusSync: | |
| 109 | + | h.wv.engine.handleEditableFocusBridge(browser) | |
| 110 | + | case rendererBridgeActionPopupOpen: | |
| 111 | + | req, err := decodeRendererBridgePopupOpenPayload([]byte(payload)) | |
| 112 | + | if err != nil { | |
| 113 | + | log.Debug().Str("action", action).Msg("cef: invalid popup_open payload") | |
| 114 | + | return 1 | |
| 115 | + | } | |
| 116 | + | h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess) | |
| 117 | + | case rendererBridgeActionPopupNavigate: | |
| 118 | + | req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload)) | |
| 119 | + | if err != nil { | |
| 120 | + | log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload") | |
| 121 | + | return 1 | |
| 122 | + | } | |
| 123 | + | h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL) | |
| 124 | + | case rendererBridgeActionPopupClose: | |
| 125 | + | req, err := decodeRendererBridgePopupClosePayload([]byte(payload)) | |
| 126 | + | if err != nil { | |
| 127 | + | log.Debug().Str("action", action).Msg("cef: invalid popup_close payload") | |
| 128 | + | return 1 | |
| 129 | + | } | |
| 130 | + | h.wv.handleSyntheticPopupClose(req.ProxyID) | |
| 131 | + | case rendererBridgeActionReady: | |
| 132 | + | log.Debug(). | |
| 133 | + | Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)). | |
| 134 | + | Msg("cef: renderer bridge ready") | |
| 135 | + | default: | |
| 136 | + | log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action") | |
| 137 | + | } | |
| 138 | + | return 1 | |
| 78 | 139 | } | |
| 79 | 140 | ||
| 80 | 141 | // =========================================================================== | |
| @@ -114,6 +175,7 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 114 | 175 | rect.Height = ht | |
| 115 | 176 | if h.wv != nil && h.wv.ctx != nil { | |
| 116 | 177 | logging.FromContext(h.wv.ctx).Debug(). | |
| 178 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 117 | 179 | Uint64("view_rect_seq", callSeq). | |
| 118 | 180 | Int32("width", rect.Width). | |
| 119 | 181 | Int32("height", rect.Height). | |
| @@ -155,6 +217,7 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||
| 155 | 217 | *info = si | |
| 156 | 218 | if h.wv != nil && h.wv.ctx != nil { | |
| 157 | 219 | logging.FromContext(h.wv.ctx).Debug(). | |
| 220 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 158 | 221 | Uint64("screen_info_seq", callSeq). | |
| 159 | 222 | Int32("width", w). | |
| 160 | 223 | Int32("height", ht). | |
| @@ -201,14 +264,27 @@ func (h *handlerSet) OnPaint( | |||
| 201 | 264 | return | |
| 202 | 265 | } | |
| 203 | 266 | paintSeq := h.wv.pipeline.nextPaintSeq() | |
| 267 | + | resizeSeq, resizeAgeMs := uint64(0), int64(0) | |
| 268 | + | if elementType != purecef.PaintElementTypePetPopup { | |
| 269 | + | resizeSeq, resizeAgeMs = h.wv.pipeline.latestResizeDiagnostics() | |
| 270 | + | } | |
| 204 | 271 | if h.wv != nil && h.wv.ctx != nil { | |
| 205 | - | logging.FromContext(h.wv.ctx).Trace(). | |
| 272 | + | log := logging.FromContext(h.wv.ctx).Trace(). | |
| 206 | 273 | Uint64("paint_seq", paintSeq). | |
| 207 | 274 | Int32("width", width). | |
| 208 | 275 | Int32("height", height). | |
| 209 | 276 | Int("dirty_rect_count", len(dirtyRects)). | |
| 210 | - | Int("buffer_len", len(buffer)). | |
| 211 | - | Msg("cef: OnPaint begin") | |
| 277 | + | Int("buffer_len", len(buffer)) | |
| 278 | + | if elementType != purecef.PaintElementTypePetPopup { | |
| 279 | + | log = log. | |
| 280 | + | Uint64("resize_seq", resizeSeq). | |
| 281 | + | Int64("time_since_resize_ms", resizeAgeMs) | |
| 282 | + | } | |
| 283 | + | log.Msg("cef: OnPaint begin") | |
| 284 | + | } | |
| 285 | + | if elementType != purecef.PaintElementTypePetPopup && | |
| 286 | + | !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) { | |
| 287 | + | return | |
| 212 | 288 | } | |
| 213 | 289 | rects := make([]rect, len(dirtyRects)) | |
| 214 | 290 | for i, dr := range dirtyRects { | |
| @@ -240,7 +316,59 @@ func (h *handlerSet) OnPaint( | |||
| 240 | 316 | } | |
| 241 | 317 | } | |
| 242 | 318 | ||
| 243 | - | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | |
| 319 | + | func (h *handlerSet) shouldAcceptMainViewPaint( | |
| 320 | + | width, height int32, | |
| 321 | + | dirtyRects []purecef.Rect, | |
| 322 | + | paintSeq uint64, | |
| 323 | + | resizeSeq uint64, | |
| 324 | + | resizeAgeMs int64, | |
| 325 | + | ) bool { | |
| 326 | + | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |
| 327 | + | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |
| 328 | + | ||
| 329 | + | if h.wv.resizeReconciler != nil { | |
| 330 | + | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | if h.wv.ctx != nil { | |
| 334 | + | if sizeMatchesCurrentView { | |
| 335 | + | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 336 | + | if shouldLog { | |
| 337 | + | logging.Trace().Mark("cef_first_content_paint") | |
| 338 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 339 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 340 | + | Uint64("paint_seq", paintSeq). | |
| 341 | + | Uint64("resize_seq", resizeSeq). | |
| 342 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 343 | + | Int32("width", width). | |
| 344 | + | Int32("height", height). | |
| 345 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 346 | + | Msg("cef: first paint after resize") | |
| 347 | + | } | |
| 348 | + | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |
| 349 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 350 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 351 | + | Uint64("paint_seq", paintSeq). | |
| 352 | + | Uint64("resize_seq", resizeSeq). | |
| 353 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 354 | + | Int32("width", width). | |
| 355 | + | Int32("height", height). | |
| 356 | + | Int32("expected_width", expectedWidth). | |
| 357 | + | Int32("expected_height", expectedHeight). | |
| 358 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 359 | + | Msg("cef: stale-size paint after resize") | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | return sizeMatchesCurrentView | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func (h *handlerSet) OnAcceleratedPaint( | |
| 367 | + | _ purecef.Browser, | |
| 368 | + | _ purecef.PaintElementType, | |
| 369 | + | _ []purecef.Rect, | |
| 370 | + | _ *purecef.AcceleratedPaintInfo, | |
| 371 | + | ) { | |
| 244 | 372 | count := h.wv.pipeline.recordAcceleratedPaint() | |
| 245 | 373 | if count <= 5 || count%100 == 0 { | |
| 246 | 374 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -254,7 +382,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA | |||
| 254 | 382 | ||
| 255 | 383 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | |
| 256 | 384 | ||
| 257 | - | func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 { | |
| 385 | + | func (h *handlerSet) StartDragging( | |
| 386 | + | _ purecef.Browser, | |
| 387 | + | _ purecef.DragData, | |
| 388 | + | _ purecef.DragOperationsMask, | |
| 389 | + | _, _ int32, | |
| 390 | + | ) int32 { | |
| 258 | 391 | return 0 | |
| 259 | 392 | } | |
| 260 | 393 | ||
| @@ -265,7 +398,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |||
| 265 | 398 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | |
| 266 | 399 | } | |
| 267 | 400 | ||
| 268 | - | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {} | |
| 401 | + | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { | |
| 402 | + | if h == nil || h.wv == nil { | |
| 403 | + | return | |
| 404 | + | } | |
| 405 | + | previous, changed := h.wv.setSelectedText(selectedText) | |
| 406 | + | if !changed { | |
| 407 | + | return | |
| 408 | + | } | |
| 409 | + | if h.wv.ctx != nil && selectedText == "" { | |
| 410 | + | if previous != "" { | |
| 411 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 412 | + | Int("prev_text_len", len(previous)). | |
| 413 | + | Msg("cef: text selection cleared") | |
| 414 | + | } | |
| 415 | + | } else if h.wv.ctx != nil { | |
| 416 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 417 | + | Int("text_len", len(selectedText)). | |
| 418 | + | Msg("cef: text selection changed") | |
| 419 | + | } | |
| 420 | + | if h.wv.engine != nil { | |
| 421 | + | h.wv.scheduleSelectionUpdate(selectedText) | |
| 422 | + | } | |
| 423 | + | } | |
| 269 | 424 | ||
| 270 | 425 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | |
| 271 | 426 | ||
| @@ -276,6 +431,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex | |||
| 276 | 431 | // OnAddressChange updates the cached URI when the main frame navigates. | |
| 277 | 432 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | |
| 278 | 433 | if frame != nil && frame.IsMain() { | |
| 434 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 435 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 436 | + | Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)). | |
| 437 | + | Msg("cef: OnAddressChange") | |
| 438 | + | } | |
| 279 | 439 | h.wv.updateURI(url) | |
| 280 | 440 | } | |
| 281 | 441 | } | |
| @@ -285,7 +445,7 @@ func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) { | |||
| 285 | 445 | h.wv.updateTitle(title) | |
| 286 | 446 | } | |
| 287 | 447 | ||
| 288 | - | func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ uintptr) {} | |
| 448 | + | func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ purecef.StringList) {} | |
| 289 | 449 | ||
| 290 | 450 | // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks. | |
| 291 | 451 | func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) { | |
| @@ -327,9 +487,16 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||
| 327 | 487 | } | |
| 328 | 488 | } | |
| 329 | 489 | ||
| 330 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | |
| 490 | + | func (h *handlerSet) OnConsoleMessage( | |
| 491 | + | _ purecef.Browser, | |
| 492 | + | level purecef.LogSeverity, | |
| 493 | + | message, source string, | |
| 494 | + | line int32, | |
| 495 | + | ) int32 { | |
| 331 | 496 | if h.wv != nil && h.wv.ctx != nil && | |
| 332 | - | (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | |
| 497 | + | (strings.Contains(message, consoleMarkerVideoDiag) || | |
| 498 | + | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |
| 499 | + | strings.Contains(message, consoleMarkerAutoCopy)) { | |
| 333 | 500 | log := logging.FromContext(h.wv.ctx).With(). | |
| 334 | 501 | Str("component", "cef-console"). | |
| 335 | 502 | Str("source", source). | |
| @@ -351,10 +518,20 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re | |||
| 351 | 518 | ||
| 352 | 519 | // OnLoadingProgressChange updates the cached progress value. | |
| 353 | 520 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | |
| 521 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 522 | + | logging.FromContext(h.wv.ctx).Trace(). | |
| 523 | + | Float64("progress", progress). | |
| 524 | + | Msg("cef: OnLoadingProgressChange") | |
| 525 | + | } | |
| 354 | 526 | h.wv.updateProgress(progress) | |
| 355 | 527 | } | |
| 356 | 528 | ||
| 357 | - | func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 { | |
| 529 | + | func (h *handlerSet) OnCursorChange( | |
| 530 | + | _ purecef.Browser, | |
| 531 | + | _ uintptr, | |
| 532 | + | cursorType purecef.CursorType, | |
| 533 | + | _ *purecef.CursorInfo, | |
| 534 | + | ) int32 { | |
| 358 | 535 | name := cefCursorToGDKName(cursorType) | |
| 359 | 536 | h.wv.runOnGTK(func() { | |
| 360 | 537 | h.wv.pipeline.glArea.SetCursorFromName(&name) | |
| @@ -376,6 +553,30 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||
| 376 | 553 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | |
| 377 | 554 | loading := isloading != 0 | |
| 378 | 555 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | |
| 556 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 557 | + | h.wv.mu.RLock() | |
| 558 | + | uri := h.wv.uri | |
| 559 | + | progress := h.wv.progress | |
| 560 | + | pendingURI := h.wv.pendingURI | |
| 561 | + | h.wv.mu.RUnlock() | |
| 562 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 563 | + | Bool("loading", loading). | |
| 564 | + | Bool("can_go_back", cangoback != 0). | |
| 565 | + | Bool("can_go_forward", cangoforward != 0). | |
| 566 | + | Float64("progress", progress). | |
| 567 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 568 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 569 | + | Msg("cef: OnLoadingStateChange") | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 573 | + | h.wv.mu.RLock() | |
| 574 | + | host := h.wv.host | |
| 575 | + | h.wv.mu.RUnlock() | |
| 576 | + | if host != nil { | |
| 577 | + | syncWindowlessBrowserFocus(host) | |
| 578 | + | } | |
| 579 | + | } | |
| 379 | 580 | ||
| 380 | 581 | h.wv.mu.RLock() | |
| 381 | 582 | cb := h.wv.callbacks | |
| @@ -400,6 +601,25 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 400 | 601 | if frame == nil || !frame.IsMain() { | |
| 401 | 602 | return | |
| 402 | 603 | } | |
| 604 | + | bridgeNonce := h.wv.ensureBridgeNonce() | |
| 605 | + | if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" { | |
| 606 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 607 | + | parentURI, active, blocked := h.wv.popupOpenerBridgeState() | |
| 608 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 609 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 610 | + | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | |
| 611 | + | Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)). | |
| 612 | + | Bool("bridge_active", active). | |
| 613 | + | Bool("bridge_blocked", blocked). | |
| 614 | + | Msg("cef: injecting popup opener bridge") | |
| 615 | + | } | |
| 616 | + | frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0) | |
| 617 | + | } | |
| 618 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 619 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 620 | + | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | |
| 621 | + | Msg("cef: OnLoadStart") | |
| 622 | + | } | |
| 403 | 623 | h.wv.updateURI(frame.GetURL()) | |
| 404 | 624 | h.wv.mu.RLock() | |
| 405 | 625 | cb := h.wv.callbacks | |
| @@ -425,6 +645,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 425 | 645 | // Successful load — reset the consecutive crash counter. | |
| 426 | 646 | h.wv.crashCount.Store(0) | |
| 427 | 647 | ||
| 648 | + | // If a queued startup navigation is still pending after about:blank finished, | |
| 649 | + | // replay it now that the initial main-frame load completed. | |
| 650 | + | frameURL := frame.GetURL() | |
| 651 | + | if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) { | |
| 652 | + | if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |
| 653 | + | log.Debug(). | |
| 654 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 655 | + | Msg("cef: replaying pending navigation after about:blank load end") | |
| 656 | + | h.wv.schedulePendingNavigationReplay(0) | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 428 | 660 | // Inject scripts and styles after page load. | |
| 429 | 661 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |
| 430 | 662 | // and JavaScript injection requires the main thread. | |
| @@ -454,13 +686,15 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E | |||
| 454 | 686 | // =========================================================================== | |
| 455 | 687 | ||
| 456 | 688 | // OnBeforePopup intercepts popup requests (target="_blank", window.open). | |
| 457 | - | // CEF OSR cannot create popup windows, so we fire the OnCreate callback | |
| 458 | - | // to let the coordinator open the link in a new stacked pane. | |
| 689 | + | // When the coordinator returns a related CEF popup shell we hand that shell's | |
| 690 | + | // client back to CEF and allow native popup creation so opener semantics are | |
| 691 | + | // preserved. Otherwise we keep blocking and let the coordinator's fallback | |
| 692 | + | // pane handle the navigation. | |
| 459 | 693 | func (h *handlerSet) OnBeforePopup( | |
| 460 | - | _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string, | |
| 694 | + | _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string, | |
| 461 | 695 | _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | |
| 462 | - | _ *purecef.WindowInfo, _ *purecef.Client, _ *purecef.BrowserSettings, | |
| 463 | - | _ *purecef.DictionaryValue, _ *bool, | |
| 696 | + | windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings, | |
| 697 | + | _ *purecef.DictionaryValue, noJavaScriptAccess *bool, | |
| 464 | 698 | ) bool { | |
| 465 | 699 | if targetURL == "" { | |
| 466 | 700 | return true | |
| @@ -469,26 +703,50 @@ func (h *handlerSet) OnBeforePopup( | |||
| 469 | 703 | h.wv.mu.RLock() | |
| 470 | 704 | cb := h.wv.callbacks | |
| 471 | 705 | h.wv.mu.RUnlock() | |
| 706 | + | if cb == nil || cb.OnCreate == nil { | |
| 707 | + | return true | |
| 708 | + | } | |
| 472 | 709 | ||
| 473 | - | if cb != nil && cb.OnCreate != nil { | |
| 474 | - | req := port.PopupRequest{ | |
| 475 | - | TargetURI: targetURL, | |
| 476 | - | FrameName: targetFrameName, | |
| 477 | - | IsUserGesture: userGesture != 0, | |
| 478 | - | ParentViewID: h.wv.id, | |
| 479 | - | } | |
| 480 | - | h.wv.runOnGTK(func() { | |
| 481 | - | cb.OnCreate(req) | |
| 710 | + | requestNoJavaScriptAccess := false | |
| 711 | + | if noJavaScriptAccess != nil { | |
| 712 | + | requestNoJavaScriptAccess = *noJavaScriptAccess | |
| 713 | + | } | |
| 714 | + | var popup port.WebView | |
| 715 | + | h.wv.runOnGTKSync(func() { | |
| 716 | + | popup = cb.OnCreate(port.PopupRequest{ | |
| 717 | + | TargetURI: targetURL, | |
| 718 | + | FrameName: targetFrameName, | |
| 719 | + | IsUserGesture: userGesture != 0, | |
| 720 | + | NoJavaScriptAccess: requestNoJavaScriptAccess, | |
| 721 | + | ParentViewID: h.wv.id, | |
| 482 | 722 | }) | |
| 723 | + | }) | |
| 724 | + | ||
| 725 | + | cefPopup, ok := popup.(*WebView) | |
| 726 | + | if !ok || cefPopup == nil { | |
| 727 | + | return true | |
| 728 | + | } | |
| 729 | + | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | |
| 730 | + | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | |
| 731 | + | return false | |
| 483 | 732 | } | |
| 484 | 733 | ||
| 485 | - | return true // always block CEF popup; the coordinator handles the new pane | |
| 734 | + | cefPopup.discardNativePopupCandidate() | |
| 735 | + | logging.FromContext(h.currentContext()).Debug(). | |
| 736 | + | Int32("popup_id", popupID). | |
| 737 | + | Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)). | |
| 738 | + | Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation") | |
| 739 | + | return true | |
| 486 | 740 | } | |
| 487 | 741 | ||
| 488 | - | func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {} | |
| 742 | + | func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) { | |
| 743 | + | if popup := h.wv.takePendingNativePopup(popupID); popup != nil { | |
| 744 | + | popup.handleNativePopupAborted() | |
| 745 | + | } | |
| 746 | + | } | |
| 489 | 747 | ||
| 490 | 748 | func (h *handlerSet) OnBeforeDevToolsPopup( | |
| 491 | - | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client, | |
| 749 | + | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot, | |
| 492 | 750 | _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool, | |
| 493 | 751 | ) { | |
| 494 | 752 | } | |
| @@ -501,46 +759,112 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 501 | 759 | return | |
| 502 | 760 | } | |
| 503 | 761 | browserID := browser.GetIdentifier() | |
| 504 | - | log.Debug(). | |
| 505 | - | Int32("browser_id", browserID). | |
| 506 | - | Bool("context_menu_handler_enabled", h.enableContextMenuHandler). | |
| 507 | - | Msg("cef: OnAfterCreated") | |
| 508 | - | if h.wv.engine != nil { | |
| 509 | - | h.wv.engine.recordBrowserAfterCreated(browser) | |
| 510 | - | h.wv.engine.registerWebView(h.wv) | |
| 511 | - | } | |
| 762 | + | log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated") | |
| 763 | + | ||
| 512 | 764 | host := browser.GetHost() | |
| 765 | + | if host == nil { | |
| 766 | + | log.Warn().Msg("cef: OnAfterCreated returned nil host") | |
| 767 | + | return | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | state := h.attachAfterCreatedBrowser(browser, host, browserID) | |
| 771 | + | if state.closeDuplicate { | |
| 772 | + | log.Warn(). | |
| 773 | + | Int32("browser_id", browserID). | |
| 774 | + | Int32("existing_browser_id", state.duplicateBrowserID). | |
| 775 | + | Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate") | |
| 776 | + | host.CloseBrowser(1) | |
| 777 | + | return | |
| 778 | + | } | |
| 513 | 779 | ||
| 780 | + | h.finishAfterCreated(browser, host, state) | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | func (h *handlerSet) attachAfterCreatedBrowser( | |
| 784 | + | browser purecef.Browser, | |
| 785 | + | host purecef.BrowserHost, | |
| 786 | + | browserID int32, | |
| 787 | + | ) afterCreatedState { | |
| 788 | + | state := afterCreatedState{} | |
| 514 | 789 | h.wv.mu.Lock() | |
| 790 | + | defer h.wv.mu.Unlock() | |
| 791 | + | ||
| 792 | + | if existing := h.wv.browser; existing != nil { | |
| 793 | + | existingID := existing.GetIdentifier() | |
| 794 | + | if existingID != 0 && existingID != browserID { | |
| 795 | + | state.closeDuplicate = true | |
| 796 | + | state.duplicateBrowserID = existingID | |
| 797 | + | return state | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | ||
| 515 | 801 | h.wv.browser = browser | |
| 516 | 802 | h.wv.host = host | |
| 517 | - | uri := h.wv.pendingURI | |
| 518 | - | h.wv.pendingURI = "" | |
| 803 | + | h.wv.pendingCreate = nil | |
| 519 | 804 | h.wv.input.setHost(host) | |
| 520 | 805 | if h.wv.findCtrl != nil { | |
| 521 | 806 | h.wv.findCtrl.setHost(host) | |
| 522 | 807 | } | |
| 808 | + | state.nativePopupParent = h.wv.nativePopupParent | |
| 809 | + | state.nativePopupID = h.wv.nativePopupID | |
| 810 | + | h.wv.nativePopupParent = nil | |
| 811 | + | h.wv.nativePopupID = 0 | |
| 812 | + | h.wv.nativePopupFallbackStarted = false | |
| 523 | 813 | ||
| 524 | 814 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 525 | - | // UI elements like the text caret until explicitly told the browser is shown. | |
| 815 | + | // painting/caret updates until explicitly told the browser is shown. | |
| 526 | 816 | host.WasHidden(0) | |
| 817 | + | state.shouldSyncFocus = h.afterCreatedShouldSyncFocus() | |
| 818 | + | state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 819 | + | return state | |
| 820 | + | } | |
| 527 | 821 | ||
| 528 | - | // If the GLArea already has GTK focus (cold start race), tell CEF now. | |
| 529 | - | // The focus-enter event fired before the browser existed, so SetFocus | |
| 530 | - | // was never called and the caret won't blink until the user clicks. | |
| 531 | - | if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() { | |
| 532 | - | host.SetFocus(1) | |
| 822 | + | func (h *handlerSet) afterCreatedShouldSyncFocus() bool { | |
| 823 | + | if h.wv.input == nil { | |
| 824 | + | return false | |
| 533 | 825 | } | |
| 534 | - | ||
| 535 | - | // Replay any navigation that was requested before the browser existed. | |
| 536 | - | if uri != "" { | |
| 537 | - | if frame := browser.GetMainFrame(); frame != nil { | |
| 538 | - | frame.LoadURL(uri) | |
| 539 | - | } | |
| 826 | + | if h.wv.input.hasGTKFocus() { | |
| 827 | + | return true | |
| 540 | 828 | } | |
| 541 | - | h.wv.mu.Unlock() | |
| 829 | + | return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() | |
| 830 | + | } | |
| 542 | 831 | ||
| 832 | + | func (h *handlerSet) finishAfterCreated( | |
| 833 | + | browser purecef.Browser, | |
| 834 | + | host purecef.BrowserHost, | |
| 835 | + | state afterCreatedState, | |
| 836 | + | ) { | |
| 837 | + | if h.wv.engine != nil { | |
| 838 | + | h.wv.engine.recordBrowserAfterCreated(browser) | |
| 839 | + | h.wv.engine.registerWebView(h.wv) | |
| 840 | + | h.wv.engine.bindBrowserWebView(browser, h.wv) | |
| 841 | + | } | |
| 842 | + | h.wv.stopNativePopupFallbackTimer() | |
| 843 | + | if state.shouldSyncFocus { | |
| 844 | + | syncWindowlessBrowserFocus(host) | |
| 845 | + | } else { | |
| 846 | + | // Request an initial OSR frame even before the first real navigation | |
| 847 | + | // commits. This restores the about:blank warm-up paint that the stable | |
| 848 | + | // startup path relied on and prevents the render pipeline from staying idle. | |
| 849 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 850 | + | } | |
| 851 | + | if state.hasPendingNavigation { | |
| 852 | + | h.wv.schedulePendingNavigationReplay(0) | |
| 853 | + | } | |
| 854 | + | if state.nativePopupParent != nil && state.nativePopupID != 0 { | |
| 855 | + | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | |
| 856 | + | } | |
| 543 | 857 | h.wv.scheduleStartBeginFrameLoop() | |
| 858 | + | h.wv.fireReadyToShow() | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | type afterCreatedState struct { | |
| 862 | + | shouldSyncFocus bool | |
| 863 | + | hasPendingNavigation bool | |
| 864 | + | nativePopupParent *WebView | |
| 865 | + | nativePopupID int32 | |
| 866 | + | duplicateBrowserID int32 | |
| 867 | + | closeDuplicate bool | |
| 544 | 868 | } | |
| 545 | 869 | ||
| 546 | 870 | // DoClose returns false to allow the default close behavior. | |
| @@ -549,9 +873,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||
| 549 | 873 | } | |
| 550 | 874 | ||
| 551 | 875 | // OnBeforeClose fires the OnClose callback. | |
| 552 | - | func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |
| 876 | + | func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |
| 553 | 877 | if h.wv.engine != nil { | |
| 554 | - | h.wv.engine.unregisterWebView(h.wv) | |
| 878 | + | browserID := int32(0) | |
| 879 | + | if browser != nil { | |
| 880 | + | browserID = browser.GetIdentifier() | |
| 881 | + | } | |
| 882 | + | h.wv.engine.unregisterWebView(h.wv, browserID) | |
| 555 | 883 | } | |
| 556 | 884 | h.wv.mu.Lock() | |
| 557 | 885 | h.wv.browser = nil | |
| @@ -561,6 +889,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 561 | 889 | h.wv.findCtrl.setHost(nil) | |
| 562 | 890 | } | |
| 563 | 891 | h.wv.mu.Unlock() | |
| 892 | + | h.wv.resizeReconciler.stop() | |
| 564 | 893 | h.wv.scheduleStopBeginFrameLoop() | |
| 565 | 894 | ||
| 566 | 895 | h.wv.mu.RLock() | |
| @@ -571,17 +900,95 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 571 | 900 | cb.OnClose() | |
| 572 | 901 | }) | |
| 573 | 902 | } | |
| 903 | + | h.wv.runCloseCallbacks() | |
| 574 | 904 | } | |
| 575 | 905 | ||
| 576 | 906 | // =========================================================================== | |
| 577 | 907 | // RequestHandler (11 methods) | |
| 578 | 908 | // =========================================================================== | |
| 579 | 909 | ||
| 580 | - | func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _, _ int32) bool { | |
| 581 | - | return false | |
| 910 | + | func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool { | |
| 911 | + | if frame == nil || !frame.IsMain() || request == nil { | |
| 912 | + | return false | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | handler := h.downloadHandler() | |
| 916 | + | if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") { | |
| 917 | + | return false | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | url := request.GetURL() | |
| 921 | + | if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil { | |
| 922 | + | return false | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | host := browser.GetHost() | |
| 926 | + | if host == nil { | |
| 927 | + | return false | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | logging.FromContext(h.currentContext()).Debug(). | |
| 931 | + | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 932 | + | Msg("cef: forcing download for navigation") | |
| 933 | + | ||
| 934 | + | host.StartDownload(url) | |
| 935 | + | return true | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool { | |
| 939 | + | handler := h.downloadHandler() | |
| 940 | + | if handler == nil { | |
| 941 | + | return false | |
| 942 | + | } | |
| 943 | + | return handler.canDownload(browser, url, requestMethod) | |
| 582 | 944 | } | |
| 583 | 945 | ||
| 584 | - | func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ string, _ purecef.WindowOpenDisposition, _ int32) int32 { | |
| 946 | + | func (h *handlerSet) OnBeforeDownload( | |
| 947 | + | browser purecef.Browser, | |
| 948 | + | downloadItem purecef.DownloadItem, | |
| 949 | + | suggestedName string, | |
| 950 | + | callback purecef.BeforeDownloadCallback, | |
| 951 | + | ) bool { | |
| 952 | + | handler := h.downloadHandler() | |
| 953 | + | if handler == nil { | |
| 954 | + | return false | |
| 955 | + | } | |
| 956 | + | return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback) | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | func (h *handlerSet) OnDownloadUpdated( | |
| 960 | + | _ purecef.Browser, | |
| 961 | + | downloadItem purecef.DownloadItem, | |
| 962 | + | callback purecef.DownloadItemCallback, | |
| 963 | + | ) { | |
| 964 | + | handler := h.downloadHandler() | |
| 965 | + | if handler == nil { | |
| 966 | + | return | |
| 967 | + | } | |
| 968 | + | handler.onDownloadUpdated(h.currentContext(), downloadItem, callback) | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | func (h *handlerSet) downloadHandler() *downloadHandler { | |
| 972 | + | if h == nil || h.wv == nil || h.wv.engine == nil { | |
| 973 | + | return nil | |
| 974 | + | } | |
| 975 | + | return h.wv.engine.currentDownloadHandler() | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | func (h *handlerSet) currentContext() context.Context { | |
| 979 | + | if h == nil || h.wv == nil || h.wv.ctx == nil { | |
| 980 | + | return context.Background() | |
| 981 | + | } | |
| 982 | + | return h.wv.ctx | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func (h *handlerSet) OnOpenUrlfromTab( | |
| 986 | + | _ purecef.Browser, | |
| 987 | + | _ purecef.Frame, | |
| 988 | + | _ string, | |
| 989 | + | _ purecef.WindowOpenDisposition, | |
| 990 | + | _ int32, | |
| 991 | + | ) int32 { | |
| 585 | 992 | return 0 | |
| 586 | 993 | } | |
| 587 | 994 | ||
| @@ -589,7 +996,8 @@ func (h *handlerSet) GetResourceRequestHandler( | |||
| 589 | 996 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 590 | 997 | _, _ int32, _ string, disableDefaultHandling *int32, | |
| 591 | 998 | ) purecef.ResourceRequestHandler { | |
| 592 | - | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | |
| 999 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && | |
| 1000 | + | transcoder.IsEagerTranscodeURL(request.GetURL()) { | |
| 593 | 1001 | *disableDefaultHandling = 1 | |
| 594 | 1002 | if h.wv != nil && h.wv.ctx != nil { | |
| 595 | 1003 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -610,7 +1018,13 @@ func (h *handlerSet) GetAuthCredentials( | |||
| 610 | 1018 | return 0 | |
| 611 | 1019 | } | |
| 612 | 1020 | ||
| 613 | - | func (h *handlerSet) OnCertificateError(_ purecef.Browser, _ purecef.Errorcode, _ string, _ purecef.Sslinfo, _ purecef.Callback) int32 { | |
| 1021 | + | func (h *handlerSet) OnCertificateError( | |
| 1022 | + | _ purecef.Browser, | |
| 1023 | + | _ purecef.Errorcode, | |
| 1024 | + | _ string, | |
| 1025 | + | _ purecef.Sslinfo, | |
| 1026 | + | _ purecef.Callback, | |
| 1027 | + | ) int32 { | |
| 614 | 1028 | return 0 | |
| 615 | 1029 | } | |
| 616 | 1030 | ||
| @@ -839,7 +1253,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |||
| 839 | 1253 | // =========================================================================== | |
| 840 | 1254 | ||
| 841 | 1255 | // OnFindResult dispatches CEF find results to the WebView's FindController. | |
| 842 | - | func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) { | |
| 1256 | + | func (h *handlerSet) OnFindResult( | |
| 1257 | + | _ purecef.Browser, | |
| 1258 | + | identifier, count int32, | |
| 1259 | + | _ *purecef.Rect, | |
| 1260 | + | activematchordinal, finalupdate int32, | |
| 1261 | + | ) { | |
| 843 | 1262 | if h.wv.findCtrl != nil { | |
| 844 | 1263 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | |
| 845 | 1264 | } | |
M internal/infrastructure/cef/webview_handlers_test.go +449 -4
| @@ -2,15 +2,64 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sync" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 9 | 13 | "github.com/stretchr/testify/require" | |
| 10 | 14 | ||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | 18 | ) | |
| 13 | 19 | ||
| 20 | + | type clipboardOrchestratorRecorder struct { | |
| 21 | + | mu sync.Mutex | |
| 22 | + | selection dto.SelectionClipboardInput | |
| 23 | + | selectionCalls int | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type stubDownloadPreparer struct{} | |
| 27 | + | ||
| 28 | + | func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |
| 29 | + | return &port.DownloadPrepareOutput{} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type controlledSelectionTimer struct { | |
| 33 | + | stopped bool | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (t *controlledSelectionTimer) Stop() bool { | |
| 37 | + | alreadyStopped := t.stopped | |
| 38 | + | t.stopped = true | |
| 39 | + | return !alreadyStopped | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type controlledSelectionScheduler struct { | |
| 43 | + | timers []*controlledSelectionTimer | |
| 44 | + | callbacks []func() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer { | |
| 48 | + | timer := &controlledSelectionTimer{} | |
| 49 | + | s.timers = append(s.timers, timer) | |
| 50 | + | s.callbacks = append(s.callbacks, fn) | |
| 51 | + | return timer | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (s *controlledSelectionScheduler) fire(index int) { | |
| 55 | + | if index < 0 || index >= len(s.callbacks) { | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | if !s.timers[index].stopped { | |
| 59 | + | s.callbacks[index]() | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 14 | 63 | func newTestPipeline(w, h, s int32) *renderPipeline { | |
| 15 | 64 | rp := &renderPipeline{scale: s} | |
| 16 | 65 | rp.widthAtomic.Store(w) | |
| @@ -52,6 +101,159 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p | |||
| 52 | 101 | return nil | |
| 53 | 102 | } | |
| 54 | 103 | ||
| 104 | + | type stubStoppableTimer struct{} | |
| 105 | + | ||
| 106 | + | func (stubStoppableTimer) Stop() bool { return true } | |
| 107 | + | ||
| 108 | + | func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) { | |
| 109 | + | wv := &WebView{ | |
| 110 | + | nativePopupCandidate: true, | |
| 111 | + | pendingCreate: &pendingBrowserCreate{}, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | require.True(t, wv.startNativePopupFallback()) | |
| 115 | + | require.False(t, wv.isNativePopupCandidate()) | |
| 116 | + | require.True(t, wv.nativePopupFallbackStarted) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestPreparePopupShellDirectBrowserCreation_EnablesSyntheticOpenerBridge(t *testing.T) { | |
| 120 | + | parent := &WebView{ctx: context.Background()} | |
| 121 | + | parent.updateURI("https://example.com/login") | |
| 122 | + | wv := &WebView{ | |
| 123 | + | pendingCreate: &pendingBrowserCreate{}, | |
| 124 | + | nativePopupParent: parent, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | require.True(t, wv.preparePopupShellDirectBrowserCreation()) | |
| 128 | + | require.Same(t, parent, wv.popupOpenerBridgeParent) | |
| 129 | + | require.Equal(t, "https://example.com/login", wv.popupOpenerBridgeParentURI) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestPreparePopupShellDirectBrowserCreation_SkipsDestroyedParentOpenerBridge(t *testing.T) { | |
| 133 | + | parent := &WebView{ctx: context.Background()} | |
| 134 | + | parent.updateURI("https://example.com/login") | |
| 135 | + | parent.destroyed.Store(true) | |
| 136 | + | wv := &WebView{ | |
| 137 | + | pendingCreate: &pendingBrowserCreate{}, | |
| 138 | + | nativePopupParent: parent, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | require.True(t, wv.preparePopupShellDirectBrowserCreation()) | |
| 142 | + | require.Nil(t, wv.popupOpenerBridgeParent) | |
| 143 | + | require.Empty(t, wv.popupOpenerBridgeParentURI) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestScheduleNativePopupFallback_SkipsDestroyedWebView(t *testing.T) { | |
| 147 | + | called := false | |
| 148 | + | wv := &WebView{} | |
| 149 | + | wv.destroyed.Store(true) | |
| 150 | + | wv.nativePopupFallbackSchedule = func(time.Duration, func()) stoppableTimer { | |
| 151 | + | called = true | |
| 152 | + | return stubStoppableTimer{} | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | wv.scheduleNativePopupFallback(time.Millisecond, func() { | |
| 156 | + | called = true | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | require.False(t, called) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestScheduleNativePopupFallback_DoesNotFireAfterDestroy(t *testing.T) { | |
| 163 | + | called := false | |
| 164 | + | var scheduled func() | |
| 165 | + | wv := &WebView{} | |
| 166 | + | wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer { | |
| 167 | + | scheduled = fn | |
| 168 | + | return stubStoppableTimer{} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | wv.scheduleNativePopupFallback(time.Millisecond, func() { | |
| 172 | + | called = true | |
| 173 | + | }) | |
| 174 | + | require.NotNil(t, scheduled) | |
| 175 | + | ||
| 176 | + | wv.destroyed.Store(true) | |
| 177 | + | scheduled() | |
| 178 | + | ||
| 179 | + | require.False(t, called) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestStartNativePopupFallback_RejectsDestroyedWebView(t *testing.T) { | |
| 183 | + | wv := &WebView{ | |
| 184 | + | nativePopupCandidate: true, | |
| 185 | + | pendingCreate: &pendingBrowserCreate{}, | |
| 186 | + | } | |
| 187 | + | wv.destroyed.Store(true) | |
| 188 | + | ||
| 189 | + | require.False(t, wv.startNativePopupFallback()) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) { | |
| 193 | + | closed := false | |
| 194 | + | parent := &WebView{} | |
| 195 | + | wv := &WebView{ | |
| 196 | + | nativePopupCandidate: true, | |
| 197 | + | nativePopupParent: parent, | |
| 198 | + | nativePopupID: 55, | |
| 199 | + | pendingCreate: &pendingBrowserCreate{}, | |
| 200 | + | pendingURI: "https://example.com/oauth", | |
| 201 | + | isLoading: true, | |
| 202 | + | closeCallbacks: []func(){ | |
| 203 | + | func() { closed = true }, | |
| 204 | + | }, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | wv.handleNativePopupAborted() | |
| 208 | + | ||
| 209 | + | require.False(t, closed) | |
| 210 | + | require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI()) | |
| 211 | + | require.False(t, wv.isNativePopupCandidate()) | |
| 212 | + | require.Same(t, parent, wv.nativePopupParent) | |
| 213 | + | require.Equal(t, int32(0), wv.nativePopupID) | |
| 214 | + | require.True(t, wv.isLoading) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) { | |
| 218 | + | parentWV := &WebView{ctx: context.Background(), id: 17} | |
| 219 | + | popupWV := &WebView{ctx: context.Background(), id: 23} | |
| 220 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 221 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 222 | + | require.Equal(t, "https://example.com/popup", req.TargetURI) | |
| 223 | + | require.Equal(t, "auth-popup", req.FrameName) | |
| 224 | + | require.True(t, req.IsUserGesture) | |
| 225 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 226 | + | return popupWV | |
| 227 | + | }, | |
| 228 | + | }) | |
| 229 | + | ||
| 230 | + | h := &handlerSet{wv: parentWV} | |
| 231 | + | blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 232 | + | ||
| 233 | + | require.True(t, blocked) | |
| 234 | + | require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI()) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) { | |
| 238 | + | parentWV := &WebView{ctx: context.Background(), id: 31} | |
| 239 | + | popupWV := &WebView{ctx: context.Background(), id: 32} | |
| 240 | + | popupWV.markNativePopupCandidate(parentWV) | |
| 241 | + | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| 242 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 243 | + | require.Equal(t, "https://example.com/login", req.TargetURI) | |
| 244 | + | popupWV.PrimePopupNavigation(req.TargetURI) | |
| 245 | + | return popupWV | |
| 246 | + | }, | |
| 247 | + | }) | |
| 248 | + | ||
| 249 | + | h := &handlerSet{wv: parentWV} | |
| 250 | + | blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil) | |
| 251 | + | ||
| 252 | + | require.True(t, blocked) | |
| 253 | + | require.False(t, popupWV.isNativePopupCandidate()) | |
| 254 | + | require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI()) | |
| 255 | + | } | |
| 256 | + | ||
| 55 | 257 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | |
| 56 | 258 | wv := &WebView{ctx: context.Background()} | |
| 57 | 259 | var gotEvents []port.LoadEvent | |
| @@ -89,6 +291,215 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||
| 89 | 291 | assert.Empty(t, gotProgress) | |
| 90 | 292 | } | |
| 91 | 293 | ||
| 294 | + | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | |
| 295 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 296 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 297 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |
| 298 | + | recorder.mu.Lock() | |
| 299 | + | defer recorder.mu.Unlock() | |
| 300 | + | recorder.selection = input | |
| 301 | + | recorder.selectionCalls++ | |
| 302 | + | }).Return(nil).Once() | |
| 303 | + | zeroDelay := time.Duration(0) | |
| 304 | + | wv := &WebView{ | |
| 305 | + | ctx: context.Background(), | |
| 306 | + | id: 42, | |
| 307 | + | selectionDebounceDelay: &zeroDelay, | |
| 308 | + | engine: &Engine{ | |
| 309 | + | clipboardTextOrchestrator: orchestrator, | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | h := &handlerSet{wv: wv} | |
| 313 | + | ||
| 314 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 315 | + | ||
| 316 | + | recorder.mu.Lock() | |
| 317 | + | defer recorder.mu.Unlock() | |
| 318 | + | require.Equal(t, 1, recorder.selectionCalls) | |
| 319 | + | require.Equal(t, "selected text", recorder.selection.Text) | |
| 320 | + | require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine) | |
| 321 | + | require.Equal(t, uint64(42), recorder.selection.ViewID) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) { | |
| 325 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 326 | + | scheduler := &controlledSelectionScheduler{} | |
| 327 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 328 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |
| 329 | + | recorder.mu.Lock() | |
| 330 | + | defer recorder.mu.Unlock() | |
| 331 | + | recorder.selection = input | |
| 332 | + | recorder.selectionCalls++ | |
| 333 | + | }).Return(nil).Once() | |
| 334 | + | wv := &WebView{ | |
| 335 | + | ctx: context.Background(), | |
| 336 | + | id: 42, | |
| 337 | + | selectionDebounceSchedule: scheduler.schedule, | |
| 338 | + | engine: &Engine{ | |
| 339 | + | clipboardTextOrchestrator: orchestrator, | |
| 340 | + | }, | |
| 341 | + | } | |
| 342 | + | h := &handlerSet{wv: wv} | |
| 343 | + | ||
| 344 | + | h.OnTextSelectionChanged(nil, "first selection", nil) | |
| 345 | + | h.OnTextSelectionChanged(nil, "second selection", nil) | |
| 346 | + | ||
| 347 | + | recorder.mu.Lock() | |
| 348 | + | gotCalls := recorder.selectionCalls | |
| 349 | + | recorder.mu.Unlock() | |
| 350 | + | require.Zero(t, gotCalls) | |
| 351 | + | require.Len(t, scheduler.callbacks, 2) | |
| 352 | + | ||
| 353 | + | scheduler.fire(0) | |
| 354 | + | recorder.mu.Lock() | |
| 355 | + | gotCalls = recorder.selectionCalls | |
| 356 | + | recorder.mu.Unlock() | |
| 357 | + | require.Zero(t, gotCalls) | |
| 358 | + | ||
| 359 | + | scheduler.fire(1) | |
| 360 | + | recorder.mu.Lock() | |
| 361 | + | defer recorder.mu.Unlock() | |
| 362 | + | require.Equal(t, 1, recorder.selectionCalls) | |
| 363 | + | require.Equal(t, "second selection", recorder.selection.Text) | |
| 364 | + | require.Equal(t, "second selection", wv.selectedTextSnapshot()) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) { | |
| 368 | + | recorder := &clipboardOrchestratorRecorder{} | |
| 369 | + | scheduler := &controlledSelectionScheduler{} | |
| 370 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 371 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |
| 372 | + | recorder.mu.Lock() | |
| 373 | + | defer recorder.mu.Unlock() | |
| 374 | + | recorder.selection = input | |
| 375 | + | recorder.selectionCalls++ | |
| 376 | + | }).Return(nil).Once() | |
| 377 | + | wv := &WebView{ | |
| 378 | + | ctx: context.Background(), | |
| 379 | + | id: 42, | |
| 380 | + | selectionDebounceSchedule: scheduler.schedule, | |
| 381 | + | engine: &Engine{ | |
| 382 | + | clipboardTextOrchestrator: orchestrator, | |
| 383 | + | }, | |
| 384 | + | } | |
| 385 | + | h := &handlerSet{wv: wv} | |
| 386 | + | frame := cefmocks.NewMockFrame(t) | |
| 387 | + | oldFactory := newRendererBridgeProcessMessage | |
| 388 | + | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |
| 389 | + | ||
| 390 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |
| 391 | + | return newTestBridgeProcessMessage(name, true) | |
| 392 | + | } | |
| 393 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |
| 394 | + | require.Equal(t, rendererBridgeMessageName, message.GetName()) | |
| 395 | + | args := message.GetArgumentList() | |
| 396 | + | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 397 | + | require.Equal(t, "1", args.GetString(1)) | |
| 398 | + | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 399 | + | }).Once() | |
| 400 | + | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | |
| 401 | + | ||
| 402 | + | h.OnTextSelectionChanged(nil, "editable selection", nil) | |
| 403 | + | require.Equal(t, "editable selection", wv.selectedTextSnapshot()) | |
| 404 | + | ||
| 405 | + | recorder.mu.Lock() | |
| 406 | + | gotCalls := recorder.selectionCalls | |
| 407 | + | recorder.mu.Unlock() | |
| 408 | + | require.Zero(t, gotCalls) | |
| 409 | + | require.Empty(t, scheduler.callbacks) | |
| 410 | + | ||
| 411 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |
| 412 | + | return newTestBridgeProcessMessage(name, false) | |
| 413 | + | } | |
| 414 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |
| 415 | + | require.Equal(t, rendererBridgeMessageName, message.GetName()) | |
| 416 | + | args := message.GetArgumentList() | |
| 417 | + | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 418 | + | require.Equal(t, "0", args.GetString(1)) | |
| 419 | + | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 420 | + | }).Once() | |
| 421 | + | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false}) | |
| 422 | + | ||
| 423 | + | h.OnTextSelectionChanged(nil, "free selection", nil) | |
| 424 | + | require.Equal(t, "free selection", wv.selectedTextSnapshot()) | |
| 425 | + | recorder.mu.Lock() | |
| 426 | + | gotCalls = recorder.selectionCalls | |
| 427 | + | recorder.mu.Unlock() | |
| 428 | + | require.Zero(t, gotCalls) | |
| 429 | + | require.Len(t, scheduler.callbacks, 1) | |
| 430 | + | ||
| 431 | + | scheduler.fire(0) | |
| 432 | + | recorder.mu.Lock() | |
| 433 | + | defer recorder.mu.Unlock() | |
| 434 | + | require.Equal(t, 1, recorder.selectionCalls) | |
| 435 | + | require.Equal(t, "free selection", recorder.selection.Text) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) { | |
| 439 | + | called := false | |
| 440 | + | wv := &WebView{closeCallbacks: []func(){func() { called = true }}} | |
| 441 | + | ||
| 442 | + | wv.Destroy() | |
| 443 | + | ||
| 444 | + | require.True(t, called) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | |
| 448 | + | scheduler := &controlledSelectionScheduler{} | |
| 449 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |
| 450 | + | wv := &WebView{ | |
| 451 | + | ctx: context.Background(), | |
| 452 | + | id: 42, | |
| 453 | + | selectionDebounceSchedule: scheduler.schedule, | |
| 454 | + | engine: &Engine{ | |
| 455 | + | clipboardTextOrchestrator: orchestrator, | |
| 456 | + | }, | |
| 457 | + | } | |
| 458 | + | h := &handlerSet{wv: wv} | |
| 459 | + | ||
| 460 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 461 | + | require.Len(t, scheduler.callbacks, 1) | |
| 462 | + | wv.Destroy() | |
| 463 | + | scheduler.fire(0) | |
| 464 | + | ||
| 465 | + | orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything) | |
| 466 | + | require.Equal(t, "selected text", wv.selectedTextSnapshot()) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | type stubEditableDomnode struct { | |
| 470 | + | editable bool | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 } | |
| 474 | + | func (n stubEditableDomnode) IsText() bool { return false } | |
| 475 | + | func (n stubEditableDomnode) IsElement() bool { return true } | |
| 476 | + | func (n stubEditableDomnode) IsEditable() bool { return n.editable } | |
| 477 | + | func (n stubEditableDomnode) IsFormControlElement() bool { return false } | |
| 478 | + | func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 } | |
| 479 | + | func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool { | |
| 480 | + | other, ok := that.(stubEditableDomnode) | |
| 481 | + | return ok && n == other | |
| 482 | + | } | |
| 483 | + | func (n stubEditableDomnode) GetName() string { return "" } | |
| 484 | + | func (n stubEditableDomnode) GetValue() string { return "" } | |
| 485 | + | func (n stubEditableDomnode) SetValue(string) int32 { return 0 } | |
| 486 | + | func (n stubEditableDomnode) GetAsMarkup() string { return "" } | |
| 487 | + | func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil } | |
| 488 | + | func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil } | |
| 489 | + | func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil } | |
| 490 | + | func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil } | |
| 491 | + | func (n stubEditableDomnode) HasChildren() bool { return false } | |
| 492 | + | func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil } | |
| 493 | + | func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil } | |
| 494 | + | func (n stubEditableDomnode) GetElementTagName() string { return "" } | |
| 495 | + | func (n stubEditableDomnode) HasElementAttributes() bool { return false } | |
| 496 | + | func (n stubEditableDomnode) HasElementAttribute(string) bool { return false } | |
| 497 | + | func (n stubEditableDomnode) GetElementAttribute(string) string { return "" } | |
| 498 | + | func (n stubEditableDomnode) GetElementAttributes(purecef.StringMap) {} | |
| 499 | + | func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 } | |
| 500 | + | func (n stubEditableDomnode) GetElementInnerText() string { return "" } | |
| 501 | + | func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 } | |
| 502 | + | ||
| 92 | 503 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |
| 93 | 504 | rect := &purecef.Rect{} | |
| 94 | 505 | h := &handlerSet{ | |
| @@ -126,14 +537,48 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||
| 126 | 537 | require.Equal(t, int32(300), info.AvailableRect.Height) | |
| 127 | 538 | } | |
| 128 | 539 | ||
| 129 | - | func TestOptionalHandlersRespectFactoryFlags(t *testing.T) { | |
| 540 | + | func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |
| 130 | 541 | h := &handlerSet{} | |
| 131 | 542 | ||
| 132 | 543 | // AudioHandler is always enabled (required for media decoding). | |
| 133 | 544 | require.Same(t, h, h.GetAudioHandler()) | |
| 134 | - | require.Nil(t, h.GetContextMenuHandler()) | |
| 545 | + | require.Same(t, h, h.GetContextMenuHandler()) | |
| 546 | + | } | |
| 135 | 547 | ||
| 136 | - | h.enableContextMenuHandler = true | |
| 548 | + | func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |
| 549 | + | eng := &Engine{} | |
| 550 | + | require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{})) | |
| 137 | 551 | ||
| 138 | - | require.Same(t, h, h.GetContextMenuHandler()) | |
| 552 | + | h := &handlerSet{ | |
| 553 | + | wv: &WebView{ | |
| 554 | + | ctx: context.Background(), | |
| 555 | + | engine: eng, | |
| 556 | + | }, | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | require.Same(t, h, h.GetDownloadHandler()) | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |
| 563 | + | wv := &WebView{ | |
| 564 | + | id: 42, | |
| 565 | + | pipeline: &renderPipeline{ | |
| 566 | + | ctx: context.Background(), | |
| 567 | + | scale: 1, | |
| 568 | + | }, | |
| 569 | + | } | |
| 570 | + | wv.pipeline.widthAtomic.Store(1269) | |
| 571 | + | wv.pipeline.heightAtomic.Store(1035) | |
| 572 | + | ||
| 573 | + | h := &handlerSet{wv: wv} | |
| 574 | + | buffer := make([]byte, 1269*2106*4) | |
| 575 | + | ||
| 576 | + | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |
| 577 | + | ||
| 578 | + | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |
| 579 | + | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |
| 580 | + | require.Nil(t, wv.pipeline.staging) | |
| 581 | + | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |
| 582 | + | require.False(t, wv.pipeline.needsUpload) | |
| 583 | + | require.Empty(t, wv.pipeline.dirtyRects) | |
| 139 | 584 | } | |
A internal/infrastructure/cef/webview_navigation_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) { | |
| 14 | + | browser := cefmocks.NewMockBrowser(t) | |
| 15 | + | frame := cefmocks.NewMockFrame(t) | |
| 16 | + | frame.EXPECT().GetURL().Return("").Once() | |
| 17 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 19 | + | ||
| 20 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |
| 21 | + | wv.replayPendingNavigation(0) | |
| 22 | + | ||
| 23 | + | wv.mu.RLock() | |
| 24 | + | defer wv.mu.RUnlock() | |
| 25 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |
| 30 | + | browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once() | |
| 31 | + | ||
| 32 | + | oldTask := cefNewTask | |
| 33 | + | oldDelayed := cefPostDelayedTask | |
| 34 | + | defer func() { | |
| 35 | + | cefNewTask = oldTask | |
| 36 | + | cefPostDelayedTask = oldDelayed | |
| 37 | + | }() | |
| 38 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 39 | + | ||
| 40 | + | scheduled := false | |
| 41 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 42 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 43 | + | require.NotNil(t, task) | |
| 44 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |
| 45 | + | scheduled = true | |
| 46 | + | return 1 | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |
| 50 | + | wv.replayPendingNavigation(0) | |
| 51 | + | ||
| 52 | + | require.True(t, scheduled) | |
| 53 | + | wv.mu.RLock() | |
| 54 | + | defer wv.mu.RUnlock() | |
| 55 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) { | |
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |
| 60 | + | ||
| 61 | + | oldTask := cefNewTask | |
| 62 | + | oldPost := cefPostTask | |
| 63 | + | oldDelayed := cefPostDelayedTask | |
| 64 | + | oldAfter := cefScheduleAfter | |
| 65 | + | defer func() { | |
| 66 | + | cefNewTask = oldTask | |
| 67 | + | cefPostTask = oldPost | |
| 68 | + | cefPostDelayedTask = oldDelayed | |
| 69 | + | cefScheduleAfter = oldAfter | |
| 70 | + | }() | |
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 72 | + | ||
| 73 | + | retried := false | |
| 74 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 75 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 76 | + | require.NotNil(t, task) | |
| 77 | + | return 0 | |
| 78 | + | } | |
| 79 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |
| 80 | + | require.Equal(t, pendingNavigationRetryDelay, delay) | |
| 81 | + | fn() | |
| 82 | + | } | |
| 83 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 84 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 85 | + | require.NotNil(t, task) | |
| 86 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |
| 87 | + | retried = true | |
| 88 | + | return 1 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |
| 92 | + | wv.schedulePendingNavigationReplay(0) | |
| 93 | + | ||
| 94 | + | require.True(t, retried) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) { | |
| 98 | + | staleBrowser := cefmocks.NewMockBrowser(t) | |
| 99 | + | activeBrowser := cefmocks.NewMockBrowser(t) | |
| 100 | + | frame := cefmocks.NewMockFrame(t) | |
| 101 | + | frame.EXPECT().GetURL().Return("").Once() | |
| 102 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |
| 103 | + | activeBrowser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 104 | + | ||
| 105 | + | oldTask := cefNewTask | |
| 106 | + | oldPost := cefPostTask | |
| 107 | + | defer func() { | |
| 108 | + | cefNewTask = oldTask | |
| 109 | + | cefPostTask = oldPost | |
| 110 | + | }() | |
| 111 | + | var scheduled purecef.Task | |
| 112 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 113 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 114 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 115 | + | scheduled = task | |
| 116 | + | return 1 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"} | |
| 120 | + | wv.schedulePendingNavigationReplay(0) | |
| 121 | + | wv.mu.Lock() | |
| 122 | + | wv.browser = activeBrowser | |
| 123 | + | wv.mu.Unlock() | |
| 124 | + | ||
| 125 | + | require.NotNil(t, scheduled) | |
| 126 | + | scheduled.Execute() | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | |
| 130 | + | browser := cefmocks.NewMockBrowser(t) | |
| 131 | + | frame := cefmocks.NewMockFrame(t) | |
| 132 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 133 | + | frame.EXPECT().GetURL().Return("").Once() | |
| 134 | + | frame.EXPECT().LoadURL("github.com/bnema").Once() | |
| 135 | + | ||
| 136 | + | oldTask := cefNewTask | |
| 137 | + | oldPost := cefPostTask | |
| 138 | + | defer func() { | |
| 139 | + | cefNewTask = oldTask | |
| 140 | + | cefPostTask = oldPost | |
| 141 | + | }() | |
| 142 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 143 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 144 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 145 | + | require.NotNil(t, task) | |
| 146 | + | task.Execute() | |
| 147 | + | return 1 | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | wv := &WebView{ctx: context.Background(), browser: browser} | |
| 151 | + | require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema")) | |
| 152 | + | ||
| 153 | + | wv.mu.RLock() | |
| 154 | + | defer wv.mu.RUnlock() | |
| 155 | + | require.Equal(t, "github.com/bnema", wv.pendingURI) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) { | |
| 159 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |
| 160 | + | ||
| 161 | + | wv.updateURI("https://github.com/bnema") | |
| 162 | + | ||
| 163 | + | require.Empty(t, wv.pendingNavigationURI()) | |
| 164 | + | } | |
A internal/infrastructure/cef/webview_popup_proxy.go +194 -0
| @@ -0,0 +1,194 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type syntheticPopupState struct { | |
| 12 | + | WebView port.WebView | |
| 13 | + | PendingURI string | |
| 14 | + | Closed bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // syntheticPopupStateLocked returns the synthetic popup state for proxyID. | |
| 18 | + | // Caller must hold wv.syntheticPopupMu. | |
| 19 | + | func (wv *WebView) syntheticPopupStateLocked(proxyID string) *syntheticPopupState { | |
| 20 | + | if wv == nil || proxyID == "" { | |
| 21 | + | return nil | |
| 22 | + | } | |
| 23 | + | if wv.syntheticPopups == nil { | |
| 24 | + | wv.syntheticPopups = make(map[string]*syntheticPopupState) | |
| 25 | + | } | |
| 26 | + | state := wv.syntheticPopups[proxyID] | |
| 27 | + | if state == nil { | |
| 28 | + | state = &syntheticPopupState{} | |
| 29 | + | wv.syntheticPopups[proxyID] = state | |
| 30 | + | } | |
| 31 | + | return state | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (wv *WebView) deleteSyntheticPopupState(proxyID string) { | |
| 35 | + | if wv == nil || proxyID == "" { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | wv.syntheticPopupMu.Lock() | |
| 40 | + | defer wv.syntheticPopupMu.Unlock() | |
| 41 | + | delete(wv.syntheticPopups, proxyID) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (wv *WebView) handleSyntheticPopupOpen(targetURL, frameName, proxyID string, userGesture, noJavaScriptAccess bool) { | |
| 45 | + | if wv == nil || proxyID == "" || wv.destroyed.Load() { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | wv.syntheticPopupMu.Lock() | |
| 50 | + | state := wv.syntheticPopupStateLocked(proxyID) | |
| 51 | + | if state.Closed { | |
| 52 | + | delete(wv.syntheticPopups, proxyID) | |
| 53 | + | wv.syntheticPopupMu.Unlock() | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | wv.syntheticPopupMu.Unlock() | |
| 57 | + | ||
| 58 | + | wv.runOnGTK(func() { | |
| 59 | + | if wv.destroyed.Load() { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | wv.syntheticPopupMu.Lock() | |
| 64 | + | if state := wv.syntheticPopups[proxyID]; state != nil && state.Closed { | |
| 65 | + | delete(wv.syntheticPopups, proxyID) | |
| 66 | + | wv.syntheticPopupMu.Unlock() | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | wv.syntheticPopupMu.Unlock() | |
| 70 | + | ||
| 71 | + | wv.mu.RLock() | |
| 72 | + | cb := wv.callbacks | |
| 73 | + | wv.mu.RUnlock() | |
| 74 | + | if cb == nil || cb.OnCreate == nil { | |
| 75 | + | wv.deleteSyntheticPopupState(proxyID) | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | popupWV := cb.OnCreate(port.PopupRequest{ | |
| 80 | + | TargetURI: targetURL, | |
| 81 | + | FrameName: frameName, | |
| 82 | + | IsUserGesture: userGesture, | |
| 83 | + | NoJavaScriptAccess: noJavaScriptAccess, | |
| 84 | + | ParentViewID: wv.id, | |
| 85 | + | }) | |
| 86 | + | if popupWV == nil { | |
| 87 | + | wv.deleteSyntheticPopupState(proxyID) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | if cefPopup, ok := popupWV.(*WebView); ok { | |
| 91 | + | cefPopup.setPopupNoJavaScriptAccess(noJavaScriptAccess) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | pendingURI := "" | |
| 95 | + | wv.syntheticPopupMu.Lock() | |
| 96 | + | state := wv.syntheticPopupStateLocked(proxyID) | |
| 97 | + | if state.Closed { | |
| 98 | + | delete(wv.syntheticPopups, proxyID) | |
| 99 | + | wv.syntheticPopupMu.Unlock() | |
| 100 | + | closeSyntheticPopupWebView(popupWV) | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | state.WebView = popupWV | |
| 104 | + | pendingURI = strings.TrimSpace(state.PendingURI) | |
| 105 | + | wv.syntheticPopupMu.Unlock() | |
| 106 | + | ||
| 107 | + | if pendingURI == "" || pendingURI == strings.TrimSpace(targetURL) || popupWV.IsDestroyed() { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | if err := popupWV.LoadURI(context.Background(), pendingURI); err != nil { | |
| 111 | + | logging.FromContext(wv.ctx).Warn(). | |
| 112 | + | Err(err). | |
| 113 | + | Str("proxy_id", proxyID). | |
| 114 | + | Str("uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 115 | + | Msg("cef: failed to replay synthetic popup navigation") | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (wv *WebView) handleSyntheticPopupNavigate(proxyID, targetURL string) { | |
| 121 | + | if wv == nil || proxyID == "" || wv.destroyed.Load() { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | trimmedURI := strings.TrimSpace(targetURL) | |
| 126 | + | wv.syntheticPopupMu.Lock() | |
| 127 | + | state := wv.syntheticPopupStateLocked(proxyID) | |
| 128 | + | if state.Closed { | |
| 129 | + | wv.syntheticPopupMu.Unlock() | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | state.PendingURI = trimmedURI | |
| 133 | + | popupWV := state.WebView | |
| 134 | + | wv.syntheticPopupMu.Unlock() | |
| 135 | + | ||
| 136 | + | if trimmedURI == "" || popupWV == nil { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | wv.runOnGTK(func() { | |
| 141 | + | if popupWV.IsDestroyed() { | |
| 142 | + | wv.syntheticPopupMu.Lock() | |
| 143 | + | delete(wv.syntheticPopups, proxyID) | |
| 144 | + | wv.syntheticPopupMu.Unlock() | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | if err := popupWV.LoadURI(context.Background(), trimmedURI); err != nil { | |
| 148 | + | logging.FromContext(wv.ctx).Warn(). | |
| 149 | + | Err(err). | |
| 150 | + | Str("proxy_id", proxyID). | |
| 151 | + | Str("uri", logging.TruncateURL(trimmedURI, logging.PermissionLogURLMaxLen)). | |
| 152 | + | Msg("cef: failed to navigate synthetic popup") | |
| 153 | + | } | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (wv *WebView) handleSyntheticPopupClose(proxyID string) { | |
| 158 | + | if wv == nil || proxyID == "" || wv.destroyed.Load() { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | wv.syntheticPopupMu.Lock() | |
| 163 | + | if wv.syntheticPopups == nil { | |
| 164 | + | wv.syntheticPopupMu.Unlock() | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | state := wv.syntheticPopups[proxyID] | |
| 168 | + | if state == nil { | |
| 169 | + | wv.syntheticPopupMu.Unlock() | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | state.Closed = true | |
| 173 | + | popupWV := state.WebView | |
| 174 | + | delete(wv.syntheticPopups, proxyID) | |
| 175 | + | wv.syntheticPopupMu.Unlock() | |
| 176 | + | if popupWV == nil { | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | wv.runOnGTK(func() { | |
| 181 | + | closeSyntheticPopupWebView(popupWV) | |
| 182 | + | }) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func closeSyntheticPopupWebView(popupWV port.WebView) { | |
| 186 | + | if popupWV == nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | if closeable, ok := popupWV.(port.OAuthCallbackCapable); ok { | |
| 190 | + | closeable.Close() | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | popupWV.Destroy() | |
| 194 | + | } | |
A internal/infrastructure/cef/webview_popup_proxy_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type closeablePopupWebViewStub struct { | |
| 15 | + | *portmocks.MockWebView | |
| 16 | + | closed bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *closeablePopupWebViewStub) AddCloseCallback(func()) {} | |
| 20 | + | func (s *closeablePopupWebViewStub) AddNavigationCallback(func(string)) {} | |
| 21 | + | func (s *closeablePopupWebViewStub) Close() { s.closed = true } | |
| 22 | + | ||
| 23 | + | func TestHandleSyntheticPopupOpen_ReplaysQueuedNavigation(t *testing.T) { | |
| 24 | + | popupWV := portmocks.NewMockWebView(t) | |
| 25 | + | popupWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 26 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/callback").Return(nil).Once() | |
| 27 | + | ||
| 28 | + | parentWV := &WebView{ | |
| 29 | + | ctx: context.Background(), | |
| 30 | + | id: port.WebViewID(17), | |
| 31 | + | callbacks: &port.WebViewCallbacks{ | |
| 32 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 33 | + | require.Empty(t, req.TargetURI) | |
| 34 | + | require.Equal(t, "_blank", req.FrameName) | |
| 35 | + | require.True(t, req.IsUserGesture) | |
| 36 | + | require.True(t, req.NoJavaScriptAccess) | |
| 37 | + | require.Equal(t, port.WebViewID(17), req.ParentViewID) | |
| 38 | + | return popupWV | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | parentWV.handleSyntheticPopupNavigate("popup-1", "https://example.com/callback") | |
| 44 | + | parentWV.handleSyntheticPopupOpen("", "_blank", "popup-1", true, true) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestHandleSyntheticPopupNavigate_LoadsMappedPopup(t *testing.T) { | |
| 48 | + | popupWV := portmocks.NewMockWebView(t) | |
| 49 | + | popupWV.EXPECT().IsDestroyed().Return(false).Once() | |
| 50 | + | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/finish").Return(nil).Once() | |
| 51 | + | ||
| 52 | + | parentWV := &WebView{ | |
| 53 | + | ctx: context.Background(), | |
| 54 | + | id: port.WebViewID(23), | |
| 55 | + | callbacks: &port.WebViewCallbacks{ | |
| 56 | + | OnCreate: func(req port.PopupRequest) port.WebView { | |
| 57 | + | require.Equal(t, "https://example.com/start", req.TargetURI) | |
| 58 | + | require.Equal(t, "auth-popup", req.FrameName) | |
| 59 | + | require.False(t, req.NoJavaScriptAccess) | |
| 60 | + | return popupWV | |
| 61 | + | }, | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | parentWV.handleSyntheticPopupOpen("https://example.com/start", "auth-popup", "popup-2", true, false) | |
| 66 | + | parentWV.handleSyntheticPopupNavigate("popup-2", "https://example.com/finish") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestHandleSyntheticPopupOpen_CleansUpStateWhenPopupIsBlocked(t *testing.T) { | |
| 70 | + | parentWV := &WebView{ | |
| 71 | + | ctx: context.Background(), | |
| 72 | + | id: port.WebViewID(28), | |
| 73 | + | callbacks: &port.WebViewCallbacks{ | |
| 74 | + | OnCreate: func(port.PopupRequest) port.WebView { | |
| 75 | + | return nil | |
| 76 | + | }, | |
| 77 | + | }, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | parentWV.handleSyntheticPopupNavigate("popup-blocked", "https://example.com/blocked") | |
| 81 | + | parentWV.handleSyntheticPopupOpen("https://example.com/blocked", "_blank", "popup-blocked", true, false) | |
| 82 | + | ||
| 83 | + | require.Empty(t, parentWV.syntheticPopups) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestHandleSyntheticPopupClose_ClosesMappedPopup(t *testing.T) { | |
| 87 | + | popupWV := &closeablePopupWebViewStub{MockWebView: portmocks.NewMockWebView(t)} | |
| 88 | + | parentWV := &WebView{ | |
| 89 | + | ctx: context.Background(), | |
| 90 | + | id: port.WebViewID(29), | |
| 91 | + | syntheticPopups: map[string]*syntheticPopupState{ | |
| 92 | + | "popup-3": {WebView: popupWV}, | |
| 93 | + | }, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | parentWV.handleSyntheticPopupClose("popup-3") | |
| 97 | + | ||
| 98 | + | require.True(t, popupWV.closed) | |
| 99 | + | require.Empty(t, parentWV.syntheticPopups) | |
| 100 | + | } | |
M internal/infrastructure/clipboard/clipboard.go +313 -28
| @@ -1,7 +1,8 @@ | |||
| 1 | - | // Package clipboard provides a clipboard adapter using wl-clipboard (Wayland) with X11 fallback. | |
| 1 | + | // Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback. | |
| 2 | 2 | package clipboard | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| @@ -9,49 +10,266 @@ import ( | |||
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 16 | + | "github.com/bnema/puregotk/v4/gio" | |
| 17 | + | "github.com/bnema/puregotk/v4/glib" | |
| 18 | + | "github.com/rs/zerolog" | |
| 13 | 19 | ) | |
| 14 | 20 | ||
| 15 | - | // Adapter implements port.Clipboard using system clipboard tools. | |
| 16 | - | // Uses wl-clipboard for Wayland, falls back to xclip for X11. | |
| 21 | + | type toolkitClipboard interface { | |
| 22 | + | ReadText(ctx context.Context) (string, error) | |
| 23 | + | WriteText(ctx context.Context, text string) error | |
| 24 | + | WriteImage(ctx context.Context, image entity.ImageData) error | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11. | |
| 28 | + | // If no system clipboard tool is available, it falls back to the toolkit clipboard. | |
| 17 | 29 | type Adapter struct { | |
| 30 | + | toolkit toolkitClipboard | |
| 18 | 31 | copyCmd string | |
| 19 | 32 | pasteCmd string | |
| 20 | 33 | } | |
| 21 | 34 | ||
| 35 | + | func (a *Adapter) ensureToolkitClipboard() toolkitClipboard { | |
| 36 | + | if a == nil { | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | if a.toolkit == nil { | |
| 40 | + | a.toolkit = newToolkitClipboard() | |
| 41 | + | } | |
| 42 | + | return a.toolkit | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | var ( | |
| 46 | + | lookPath = exec.LookPath | |
| 47 | + | commandContext = exec.CommandContext | |
| 48 | + | newToolkitClipboard = defaultToolkitClipboard | |
| 49 | + | ) | |
| 50 | + | ||
| 22 | 51 | // New creates a new clipboard adapter. | |
| 23 | 52 | // Detects Wayland vs X11 and selects appropriate clipboard tool. | |
| 24 | 53 | func New() port.Clipboard { | |
| 25 | - | a := &Adapter{} | |
| 54 | + | a := &Adapter{toolkit: newToolkitClipboard()} | |
| 55 | + | a.copyCmd, a.pasteCmd = selectSystemClipboard() | |
| 56 | + | return a | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func defaultToolkitClipboard() toolkitClipboard { | |
| 60 | + | display := gdk.DisplayGetDefault() | |
| 61 | + | if display == nil { | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | clipboard := display.GetClipboard() | |
| 66 | + | if clipboard == nil { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | return &gdkToolkitClipboard{clipboard: clipboard} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | type gdkToolkitClipboard struct { | |
| 74 | + | clipboard *gdk.Clipboard | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error { | |
| 78 | + | return withToolkitClipboard(ctx, func() error { | |
| 79 | + | if c == nil || c.clipboard == nil { | |
| 80 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 81 | + | } | |
| 82 | + | c.clipboard.SetText(text) | |
| 83 | + | return nil | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) { | |
| 88 | + | var text string | |
| 89 | + | err := withToolkitClipboard(ctx, func() error { | |
| 90 | + | if c == nil || c.clipboard == nil { | |
| 91 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | resultCh := make(chan struct { | |
| 95 | + | text string | |
| 96 | + | err error | |
| 97 | + | }, 1) | |
| 98 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) { | |
| 99 | + | asyncResult := &gio.AsyncResultBase{} | |
| 100 | + | asyncResult.SetGoPointer(result) | |
| 101 | + | readText, readErr := c.clipboard.ReadTextFinish(asyncResult) | |
| 102 | + | resultCh <- struct { | |
| 103 | + | text string | |
| 104 | + | err error | |
| 105 | + | }{text: readText, err: readErr} | |
| 106 | + | }) | |
| 107 | + | c.clipboard.ReadTextAsync(nil, &cb, 0) | |
| 108 | + | ||
| 109 | + | mainContext := glib.MainContextDefault() | |
| 110 | + | if mainContext == nil { | |
| 111 | + | return fmt.Errorf("toolkit main context not available") | |
| 112 | + | } | |
| 26 | 113 | ||
| 27 | - | // Check for Wayland first | |
| 114 | + | for { | |
| 115 | + | select { | |
| 116 | + | case result := <-resultCh: | |
| 117 | + | text = result.text | |
| 118 | + | return result.err | |
| 119 | + | default: | |
| 120 | + | } | |
| 121 | + | if ctx.Err() != nil { | |
| 122 | + | return ctx.Err() | |
| 123 | + | } | |
| 124 | + | mainContext.Iteration(true) | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | return text, err | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 131 | + | return withToolkitClipboard(ctx, func() error { | |
| 132 | + | if c == nil || c.clipboard == nil { | |
| 133 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | if texture == nil { | |
| 141 | + | return fmt.Errorf("toolkit clipboard returned nil texture") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | c.clipboard.SetTexture(texture) | |
| 145 | + | return nil | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func withToolkitClipboard(ctx context.Context, fn func() error) error { | |
| 150 | + | mainContext := glib.MainContextDefault() | |
| 151 | + | if mainContext == nil || mainContext.IsOwner() { | |
| 152 | + | return fn() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | resultCh := make(chan error, 1) | |
| 156 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 157 | + | resultCh <- fn() | |
| 158 | + | return false | |
| 159 | + | }) | |
| 160 | + | glib.IdleAdd(&cb, 0) | |
| 161 | + | select { | |
| 162 | + | case err := <-resultCh: | |
| 163 | + | return err | |
| 164 | + | case <-ctx.Done(): | |
| 165 | + | return ctx.Err() | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func selectSystemClipboard() (copyCmd, pasteCmd string) { | |
| 170 | + | // Check for Wayland first. | |
| 28 | 171 | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 29 | - | if path, err := exec.LookPath("wl-copy"); err == nil { | |
| 30 | - | a.copyCmd = path | |
| 31 | - | if pastePath, err := exec.LookPath("wl-paste"); err == nil { | |
| 32 | - | a.pasteCmd = pastePath | |
| 172 | + | if path, err := lookPath("wl-copy"); err == nil { | |
| 173 | + | copyCmd = path | |
| 174 | + | if pastePath, err := lookPath("wl-paste"); err == nil { | |
| 175 | + | pasteCmd = pastePath | |
| 33 | 176 | } | |
| 34 | 177 | } | |
| 35 | 178 | } | |
| 36 | 179 | ||
| 37 | - | // Fall back to X11 if Wayland tools not available | |
| 38 | - | if a.copyCmd == "" && os.Getenv("DISPLAY") != "" { | |
| 39 | - | if path, err := exec.LookPath("xclip"); err == nil { | |
| 40 | - | a.copyCmd = path | |
| 41 | - | a.pasteCmd = path | |
| 42 | - | } else if path, err := exec.LookPath("xsel"); err == nil { | |
| 43 | - | a.copyCmd = path | |
| 44 | - | a.pasteCmd = path | |
| 180 | + | // Fall back to X11 if Wayland tools are not available. | |
| 181 | + | if copyCmd == "" && os.Getenv("DISPLAY") != "" { | |
| 182 | + | if path, err := lookPath("xclip"); err == nil { | |
| 183 | + | copyCmd = path | |
| 184 | + | pasteCmd = path | |
| 185 | + | } else if path, err := lookPath("xsel"); err == nil { | |
| 186 | + | copyCmd = path | |
| 187 | + | pasteCmd = path | |
| 45 | 188 | } | |
| 46 | 189 | } | |
| 47 | 190 | ||
| 48 | - | return a | |
| 191 | + | return copyCmd, pasteCmd | |
| 49 | 192 | } | |
| 50 | 193 | ||
| 51 | 194 | // WriteText copies text to the clipboard. | |
| 52 | 195 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | |
| 53 | 196 | log := logging.FromContext(ctx) | |
| 197 | + | var commandErr error | |
| 198 | + | var toolkitErr error | |
| 199 | + | if a.copyCmd != "" { | |
| 200 | + | err := a.writeTextWithCommand(ctx, text, log) | |
| 201 | + | if err == nil { | |
| 202 | + | return nil | |
| 203 | + | } | |
| 204 | + | commandErr = err | |
| 205 | + | } | |
| 206 | + | if toolkit := a.ensureToolkitClipboard(); toolkit != nil { | |
| 207 | + | err := toolkit.WriteText(ctx, text) | |
| 208 | + | if err == nil { | |
| 209 | + | log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success") | |
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | toolkitErr = err | |
| 213 | + | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | |
| 214 | + | } | |
| 215 | + | if commandErr != nil { | |
| 216 | + | return commandErr | |
| 217 | + | } | |
| 218 | + | if toolkitErr != nil { | |
| 219 | + | log.Error().Err(toolkitErr).Msg("clipboard write failed") | |
| 220 | + | return toolkitErr | |
| 221 | + | } | |
| 222 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 223 | + | log.Error().Err(err).Msg("clipboard write failed") | |
| 224 | + | return err | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // WriteImage copies image bytes to the clipboard. | |
| 228 | + | // xsel is not supported because the adapter has no reliable binary image mode | |
| 229 | + | // for it in this codebase. | |
| 230 | + | func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 231 | + | log := logging.FromContext(ctx) | |
| 232 | + | var commandErr error | |
| 233 | + | var toolkitErr error | |
| 234 | + | ||
| 235 | + | if len(image.Bytes) == 0 { | |
| 236 | + | err := fmt.Errorf("empty image data") | |
| 237 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 238 | + | return err | |
| 239 | + | } | |
| 240 | + | if toolkit := a.ensureToolkitClipboard(); toolkit != nil { | |
| 241 | + | err := toolkit.WriteImage(ctx, image) | |
| 242 | + | if err == nil { | |
| 243 | + | log.Debug(). | |
| 244 | + | Str("backend", "toolkit"). | |
| 245 | + | Str("mime_type", image.MimeType). | |
| 246 | + | Int("bytes", len(image.Bytes)). | |
| 247 | + | Msg("clipboard image write success") | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | toolkitErr = err | |
| 251 | + | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |
| 252 | + | } | |
| 253 | + | if a.copyCmd != "" { | |
| 254 | + | err := a.writeImageWithCommand(ctx, image, log) | |
| 255 | + | if err == nil { | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | commandErr = err | |
| 259 | + | } | |
| 260 | + | if commandErr != nil { | |
| 261 | + | return commandErr | |
| 262 | + | } | |
| 263 | + | if toolkitErr != nil { | |
| 264 | + | log.Error().Err(toolkitErr).Msg("clipboard image write failed") | |
| 265 | + | return toolkitErr | |
| 266 | + | } | |
| 267 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 268 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 269 | + | return err | |
| 270 | + | } | |
| 54 | 271 | ||
| 272 | + | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | |
| 55 | 273 | if a.copyCmd == "" { | |
| 56 | 274 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 57 | 275 | log.Error().Err(err).Msg("clipboard write failed") | |
| @@ -60,11 +278,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 60 | 278 | ||
| 61 | 279 | var cmd *exec.Cmd | |
| 62 | 280 | if strings.Contains(a.copyCmd, "wl-copy") { | |
| 63 | - | cmd = exec.CommandContext(ctx, a.copyCmd) | |
| 281 | + | cmd = commandContext(ctx, a.copyCmd) | |
| 64 | 282 | } else if strings.Contains(a.copyCmd, "xclip") { | |
| 65 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard") | |
| 283 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard") | |
| 66 | 284 | } else if strings.Contains(a.copyCmd, "xsel") { | |
| 67 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input") | |
| 285 | + | cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input") | |
| 68 | 286 | } else { | |
| 69 | 287 | err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd) | |
| 70 | 288 | log.Error().Err(err).Msg("clipboard write failed") | |
| @@ -73,6 +291,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 73 | 291 | ||
| 74 | 292 | cmd.Stdin = strings.NewReader(text) | |
| 75 | 293 | if err := cmd.Run(); err != nil { | |
| 294 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |
| 76 | 295 | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed") | |
| 77 | 296 | return err | |
| 78 | 297 | } | |
| @@ -81,10 +300,40 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 81 | 300 | return nil | |
| 82 | 301 | } | |
| 83 | 302 | ||
| 84 | - | // ReadText reads text from the clipboard. | |
| 85 | - | func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |
| 86 | - | log := logging.FromContext(ctx) | |
| 303 | + | func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error { | |
| 304 | + | if a.copyCmd == "" { | |
| 305 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 306 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | mimeType := image.MimeType | |
| 310 | + | if mimeType == "" { | |
| 311 | + | mimeType = "image/png" | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | var cmd *exec.Cmd | |
| 315 | + | if strings.Contains(a.copyCmd, "wl-copy") { | |
| 316 | + | cmd = commandContext(ctx, a.copyCmd, "--type", mimeType) | |
| 317 | + | } else if strings.Contains(a.copyCmd, "xclip") { | |
| 318 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i") | |
| 319 | + | } else { | |
| 320 | + | err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd) | |
| 321 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 322 | + | return err | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | cmd.Stdin = bytes.NewReader(image.Bytes) | |
| 326 | + | if err := cmd.Run(); err != nil { | |
| 327 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |
| 328 | + | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed") | |
| 329 | + | return err | |
| 330 | + | } | |
| 87 | 331 | ||
| 332 | + | log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) { | |
| 88 | 337 | if a.pasteCmd == "" { | |
| 89 | 338 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 90 | 339 | log.Error().Err(err).Msg("clipboard read failed") | |
| @@ -93,11 +342,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |||
| 93 | 342 | ||
| 94 | 343 | var cmd *exec.Cmd | |
| 95 | 344 | if strings.Contains(a.pasteCmd, "wl-paste") { | |
| 96 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline") | |
| 345 | + | cmd = commandContext(ctx, a.pasteCmd, "--no-newline") | |
| 97 | 346 | } else if strings.Contains(a.pasteCmd, "xclip") { | |
| 98 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") | |
| 347 | + | cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") | |
| 99 | 348 | } else if strings.Contains(a.pasteCmd, "xsel") { | |
| 100 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output") | |
| 349 | + | cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output") | |
| 101 | 350 | } else { | |
| 102 | 351 | err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd) | |
| 103 | 352 | log.Error().Err(err).Msg("clipboard read failed") | |
| @@ -106,7 +355,8 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |||
| 106 | 355 | ||
| 107 | 356 | out, err := cmd.Output() | |
| 108 | 357 | if err != nil { | |
| 109 | - | log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed (may be empty)") | |
| 358 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err) | |
| 359 | + | log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed") | |
| 110 | 360 | return "", err | |
| 111 | 361 | } | |
| 112 | 362 | ||
| @@ -114,6 +364,41 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |||
| 114 | 364 | return string(out), nil | |
| 115 | 365 | } | |
| 116 | 366 | ||
| 367 | + | // ReadText reads text from the clipboard. | |
| 368 | + | func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |
| 369 | + | log := logging.FromContext(ctx) | |
| 370 | + | var commandErr error | |
| 371 | + | ||
| 372 | + | if a.pasteCmd != "" { | |
| 373 | + | text, err := a.readTextWithCommand(ctx, log) | |
| 374 | + | if err == nil { | |
| 375 | + | return text, nil | |
| 376 | + | } | |
| 377 | + | commandErr = err | |
| 378 | + | log.Debug().Err(err).Msg("system clipboard read failed; falling back") | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | if toolkit := a.ensureToolkitClipboard(); toolkit != nil { | |
| 382 | + | text, err := toolkit.ReadText(ctx) | |
| 383 | + | if err == nil { | |
| 384 | + | log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success") | |
| 385 | + | return text, nil | |
| 386 | + | } | |
| 387 | + | log.Debug().Err(err).Msg("toolkit clipboard read failed") | |
| 388 | + | if commandErr != nil { | |
| 389 | + | return "", commandErr | |
| 390 | + | } | |
| 391 | + | return "", err | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | if commandErr != nil { | |
| 395 | + | return "", commandErr | |
| 396 | + | } | |
| 397 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 398 | + | log.Error().Err(err).Msg("clipboard read failed") | |
| 399 | + | return "", err | |
| 400 | + | } | |
| 401 | + | ||
| 117 | 402 | // Clear clears the clipboard contents. | |
| 118 | 403 | func (a *Adapter) Clear(ctx context.Context) error { | |
| 119 | 404 | return a.WriteText(ctx, "") | |
A internal/infrastructure/clipboard/clipboard_test.go +245 -0
| @@ -0,0 +1,245 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type fakeToolkitClipboard struct { | |
| 16 | + | writeTextCalls int | |
| 17 | + | readTextCalls int | |
| 18 | + | writeImageCalls int | |
| 19 | + | text string | |
| 20 | + | image entity.ImageData | |
| 21 | + | writeTextErr error | |
| 22 | + | readTextErr error | |
| 23 | + | writeImageErr error | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |
| 27 | + | f.writeTextCalls++ | |
| 28 | + | f.text = text | |
| 29 | + | return f.writeTextErr | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (f *fakeToolkitClipboard) ReadText(_ context.Context) (string, error) { | |
| 33 | + | f.readTextCalls++ | |
| 34 | + | return f.text, f.readTextErr | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error { | |
| 38 | + | f.writeImageCalls++ | |
| 39 | + | f.image = image | |
| 40 | + | return f.writeImageErr | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) { | |
| 44 | + | fake := &fakeToolkitClipboard{text: "toolkit text"} | |
| 45 | + | adapter := &Adapter{toolkit: fake} | |
| 46 | + | ||
| 47 | + | text, err := adapter.ReadText(context.Background()) | |
| 48 | + | ||
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.Equal(t, "toolkit text", text) | |
| 51 | + | require.Equal(t, 1, fake.readTextCalls) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) { | |
| 55 | + | fake := &fakeToolkitClipboard{text: "toolkit text"} | |
| 56 | + | adapter := &Adapter{toolkit: fake} | |
| 57 | + | ||
| 58 | + | hasText, err := adapter.HasText(context.Background()) | |
| 59 | + | ||
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.True(t, hasText) | |
| 62 | + | require.Equal(t, 1, fake.readTextCalls) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) { | |
| 66 | + | sentinel := errors.New("toolkit text failed") | |
| 67 | + | fake := &fakeToolkitClipboard{writeTextErr: sentinel} | |
| 68 | + | adapter := &Adapter{toolkit: fake} | |
| 69 | + | ||
| 70 | + | err := adapter.WriteText(context.Background(), "hello") | |
| 71 | + | ||
| 72 | + | require.Equal(t, sentinel, err) | |
| 73 | + | require.Equal(t, 1, fake.writeTextCalls) | |
| 74 | + | require.Equal(t, "hello", fake.text) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestAdapter_WriteImageReturnsToolkitErrorWhenNoSystemTool(t *testing.T) { | |
| 78 | + | sentinel := errors.New("toolkit image failed") | |
| 79 | + | fake := &fakeToolkitClipboard{writeImageErr: sentinel} | |
| 80 | + | adapter := &Adapter{toolkit: fake} | |
| 81 | + | ||
| 82 | + | err := adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"}) | |
| 83 | + | ||
| 84 | + | require.Equal(t, sentinel, err) | |
| 85 | + | require.Equal(t, 1, fake.writeImageCalls) | |
| 86 | + | require.Equal(t, entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"}, fake.image) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) { | |
| 90 | + | oldToolkitFactory := newToolkitClipboard | |
| 91 | + | oldLookPath := lookPath | |
| 92 | + | t.Cleanup(func() { | |
| 93 | + | newToolkitClipboard = oldToolkitFactory | |
| 94 | + | lookPath = oldLookPath | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | fake := &fakeToolkitClipboard{} | |
| 98 | + | newToolkitClipboard = func() toolkitClipboard { | |
| 99 | + | return fake | |
| 100 | + | } | |
| 101 | + | lookPath = func(_ string) (string, error) { | |
| 102 | + | return "", errors.New("missing") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |
| 106 | + | t.Setenv("DISPLAY", ":0") | |
| 107 | + | ||
| 108 | + | adapter := New().(*Adapter) | |
| 109 | + | require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}})) | |
| 110 | + | require.Equal(t, 1, fake.writeImageCalls) | |
| 111 | + | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) { | |
| 115 | + | oldToolkitFactory := newToolkitClipboard | |
| 116 | + | t.Cleanup(func() { | |
| 117 | + | newToolkitClipboard = oldToolkitFactory | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | dir := t.TempDir() | |
| 121 | + | scriptPath := filepath.Join(dir, "wl-copy") | |
| 122 | + | stdinPath := filepath.Join(dir, "stdin.bin") | |
| 123 | + | script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n" | |
| 124 | + | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |
| 125 | + | ||
| 126 | + | fake := &fakeToolkitClipboard{} | |
| 127 | + | newToolkitClipboard = func() toolkitClipboard { return fake } | |
| 128 | + | adapter := &Adapter{copyCmd: scriptPath} | |
| 129 | + | image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"} | |
| 130 | + | ||
| 131 | + | require.NoError(t, adapter.WriteImage(context.Background(), image)) | |
| 132 | + | require.Equal(t, 1, fake.writeImageCalls) | |
| 133 | + | require.Equal(t, image, fake.image) | |
| 134 | + | ||
| 135 | + | _, err := os.Stat(stdinPath) | |
| 136 | + | require.Error(t, err) | |
| 137 | + | require.True(t, os.IsNotExist(err)) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) { | |
| 141 | + | dir := t.TempDir() | |
| 142 | + | scriptPath := filepath.Join(dir, "wl-copy") | |
| 143 | + | stdinPath := filepath.Join(dir, "stdin.bin") | |
| 144 | + | script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n" | |
| 145 | + | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |
| 146 | + | ||
| 147 | + | adapter := &Adapter{copyCmd: scriptPath} | |
| 148 | + | image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"} | |
| 149 | + | ||
| 150 | + | require.NoError(t, adapter.WriteImage(context.Background(), image)) | |
| 151 | + | ||
| 152 | + | stdin, err := os.ReadFile(stdinPath) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.Equal(t, image.Bytes, stdin) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | |
| 158 | + | oldToolkitFactory := newToolkitClipboard | |
| 159 | + | oldLookPath := lookPath | |
| 160 | + | t.Cleanup(func() { | |
| 161 | + | newToolkitClipboard = oldToolkitFactory | |
| 162 | + | lookPath = oldLookPath | |
| 163 | + | }) | |
| 164 | + | ||
| 165 | + | newToolkitClipboard = func() toolkitClipboard { return nil } | |
| 166 | + | lookups := make([]string, 0, 2) | |
| 167 | + | lookPath = func(name string) (string, error) { | |
| 168 | + | lookups = append(lookups, name) | |
| 169 | + | switch name { | |
| 170 | + | case "wl-copy": | |
| 171 | + | return "", errors.New("wl-copy missing") | |
| 172 | + | case "xclip": | |
| 173 | + | return "/usr/bin/xclip", nil | |
| 174 | + | default: | |
| 175 | + | return "", errors.New("unexpected lookPath call") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |
| 180 | + | t.Setenv("DISPLAY", ":0") | |
| 181 | + | ||
| 182 | + | adapter := New().(*Adapter) | |
| 183 | + | require.Equal(t, "/usr/bin/xclip", adapter.copyCmd) | |
| 184 | + | require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd) | |
| 185 | + | require.Equal(t, []string{"wl-copy", "xclip"}, lookups) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |
| 189 | + | oldToolkitFactory := newToolkitClipboard | |
| 190 | + | oldLookPath := lookPath | |
| 191 | + | t.Cleanup(func() { | |
| 192 | + | newToolkitClipboard = oldToolkitFactory | |
| 193 | + | lookPath = oldLookPath | |
| 194 | + | }) | |
| 195 | + | ||
| 196 | + | fake := &fakeToolkitClipboard{} | |
| 197 | + | newToolkitClipboard = func() toolkitClipboard { return fake } | |
| 198 | + | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | |
| 199 | + | ||
| 200 | + | err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{}) | |
| 201 | + | require.Error(t, err) | |
| 202 | + | require.Contains(t, err.Error(), "empty image data") | |
| 203 | + | require.Zero(t, fake.writeImageCalls) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) { | |
| 207 | + | testCases := []struct { | |
| 208 | + | name string | |
| 209 | + | image entity.ImageData | |
| 210 | + | expected string | |
| 211 | + | }{ | |
| 212 | + | { | |
| 213 | + | name: "uses provided MIME type", | |
| 214 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"}, | |
| 215 | + | expected: "image/jpeg", | |
| 216 | + | }, | |
| 217 | + | { | |
| 218 | + | name: "falls back to PNG", | |
| 219 | + | image: entity.ImageData{Bytes: []byte{1, 2, 3}}, | |
| 220 | + | expected: "image/png", | |
| 221 | + | }, | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | for _, tt := range testCases { | |
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |
| 226 | + | dir := t.TempDir() | |
| 227 | + | scriptPath := filepath.Join(dir, "wl-copy") | |
| 228 | + | argsPath := filepath.Join(dir, "args.txt") | |
| 229 | + | stdinPath := filepath.Join(dir, "stdin.bin") | |
| 230 | + | script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n" | |
| 231 | + | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |
| 232 | + | ||
| 233 | + | adapter := &Adapter{copyCmd: scriptPath} | |
| 234 | + | require.NoError(t, adapter.WriteImage(context.Background(), tt.image)) | |
| 235 | + | ||
| 236 | + | args, err := os.ReadFile(argsPath) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n")) | |
| 239 | + | ||
| 240 | + | stdin, err := os.ReadFile(stdinPath) | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | require.Equal(t, tt.image.Bytes, stdin) | |
| 243 | + | }) | |
| 244 | + | } | |
| 245 | + | } | |
M internal/infrastructure/config/defaults.go +2 -0
| @@ -20,6 +20,7 @@ const ( | |||
| 20 | 20 | ||
| 21 | 21 | // Omnibox defaults | |
| 22 | 22 | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent | |
| 23 | + | defaultOmniboxMostVisitedDays = 30 | |
| 23 | 24 | defaultOmniboxAutoOpenOnNewPane = false | |
| 24 | 25 | ||
| 25 | 26 | // Workspace defaults | |
| @@ -284,6 +285,7 @@ func DefaultConfig() *Config { | |||
| 284 | 285 | }, | |
| 285 | 286 | Omnibox: OmniboxConfig{ | |
| 286 | 287 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 288 | + | MostVisitedDays: defaultOmniboxMostVisitedDays, | |
| 287 | 289 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | |
| 288 | 290 | }, | |
| 289 | 291 | Session: SessionConfig{ | |
M internal/infrastructure/config/defaults_test.go +0 -1
| @@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 23 | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | |
| 24 | 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 25 | 25 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 26 | - | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | |
| 27 | 26 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| 28 | 27 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
| 29 | 28 | ||
M internal/infrastructure/config/engine.go +1 -3
| @@ -85,7 +85,7 @@ type CEFEngineConfig struct { | |||
| 85 | 85 | // libcef.so and the Resources/locales subdirectories. | |
| 86 | 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | |
| 87 | 87 | // LogFile overrides the CEF internal log destination. When empty, | |
| 88 | - | // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log. | |
| 88 | + | // Dumber uses the resolved runtime-profile default engine log path. | |
| 89 | 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | |
| 90 | 90 | // LogSeverity controls CEF's internal log verbosity. | |
| 91 | 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | |
| @@ -95,8 +95,6 @@ type CEFEngineConfig struct { | |||
| 95 | 95 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | |
| 96 | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 97 | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 98 | - | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | |
| 99 | - | EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit | |
| 100 | 98 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | |
| 101 | 99 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | |
| 102 | 100 | } | |
M internal/infrastructure/config/loader.go +6 -5
| @@ -136,18 +136,19 @@ func (m *Manager) readConfigFile() error { | |||
| 136 | 136 | } | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | - | // Transform legacy action bindings before unmarshaling | |
| 140 | - | m.transformLegacyActionBindings() | |
| 139 | + | // Transform legacy action bindings and engine config before unmarshaling. | |
| 140 | + | m.transformLegacyConfig() | |
| 141 | 141 | ||
| 142 | 142 | return nil | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | - | // transformLegacyActionBindings converts old-format action bindings to new format. | |
| 145 | + | // transformLegacyConfig converts old-format action bindings and engine config to the new format. | |
| 146 | 146 | // This is called after reading config but before unmarshaling. | |
| 147 | - | func (m *Manager) transformLegacyActionBindings() { | |
| 147 | + | func (m *Manager) transformLegacyConfig() { | |
| 148 | 148 | rawConfig := m.viper.AllSettings() | |
| 149 | 149 | transformer := NewLegacyConfigTransformer() | |
| 150 | 150 | transformer.TransformLegacyActions(rawConfig) | |
| 151 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 151 | 152 | ||
| 152 | 153 | // Apply transformed config back to viper | |
| 153 | 154 | for key, value := range rawConfig { | |
| @@ -526,6 +527,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) { | |||
| 526 | 527 | ||
| 527 | 528 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | |
| 528 | 529 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |
| 530 | + | m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays) | |
| 529 | 531 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | |
| 530 | 532 | } | |
| 531 | 533 | ||
| @@ -575,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 575 | 577 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | |
| 576 | 578 | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | |
| 577 | 579 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 578 | - | m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler) | |
| 579 | 580 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | |
| 580 | 581 | ||
| 581 | 582 | wk := e.WebKit | |
M internal/infrastructure/config/migrate.go +2 -0
| @@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 380 | 380 | // Transform legacy action bindings (slice -> ActionBinding struct) | |
| 381 | 381 | transformer := NewLegacyConfigTransformer() | |
| 382 | 382 | transformer.TransformLegacyActions(rawConfig) | |
| 383 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 383 | 384 | ||
| 384 | 385 | // Build sets of keys to remove and renames to apply | |
| 385 | 386 | keysToRemove := make(map[string]bool) | |
| @@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||
| 670 | 671 | // Transform legacy action bindings before flattening | |
| 671 | 672 | transformer := NewLegacyConfigTransformer() | |
| 672 | 673 | transformer.TransformLegacyActions(rawConfig) | |
| 674 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 673 | 675 | ||
| 674 | 676 | // Flatten the map to dot-notation keys with values | |
| 675 | 677 | result := make(map[string]any) | |
M internal/infrastructure/config/schema.go +4 -0
| @@ -359,6 +359,10 @@ type OmniboxConfig struct { | |||
| 359 | 359 | // InitialBehavior controls what to show when omnibox opens with empty input | |
| 360 | 360 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |
| 361 | 361 | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | |
| 362 | + | // MostVisitedDays controls how many days of history are considered when initial behavior is most_visited. | |
| 363 | + | // 0 means all history. | |
| 364 | + | // Default: 30. | |
| 365 | + | MostVisitedDays int `mapstructure:"most_visited_days" yaml:"most_visited_days" toml:"most_visited_days"` | |
| 362 | 366 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | |
| 363 | 367 | // Default: false | |
| 364 | 368 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | |
M internal/infrastructure/config/schema_provider.go +8 -7
| @@ -672,6 +672,14 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 672 | 672 | }, | |
| 673 | 673 | Section: SectionOmnibox, | |
| 674 | 674 | }, | |
| 675 | + | { | |
| 676 | + | Key: "omnibox.most_visited_days", | |
| 677 | + | Type: "int", | |
| 678 | + | Default: fmt.Sprintf("%d", defaults.Omnibox.MostVisitedDays), | |
| 679 | + | Description: "Days of history to consider for most_visited (0 = all history)", | |
| 680 | + | Range: ">=0", | |
| 681 | + | Section: SectionOmnibox, | |
| 682 | + | }, | |
| 675 | 683 | { | |
| 676 | 684 | Key: "omnibox.auto_open_on_new_pane", | |
| 677 | 685 | Type: "bool", | |
| @@ -879,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 879 | 887 | Description: "Enable the experimental CEF audio handler bridge", | |
| 880 | 888 | Section: SectionDebug, | |
| 881 | 889 | }, | |
| 882 | - | { | |
| 883 | - | Key: "engine.cef.enable_context_menu_handler", | |
| 884 | - | Type: "bool", | |
| 885 | - | Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler), | |
| 886 | - | Description: "Enable the experimental CEF context menu handler bridge", | |
| 887 | - | Section: SectionDebug, | |
| 888 | - | }, | |
| 889 | 890 | } | |
| 890 | 891 | } | |
| 891 | 892 | ||
M internal/infrastructure/config/transform_legacy.go +15 -0
| @@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an | |||
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 45 | + | func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 46 | + | engine, ok := rawConfig["engine"].(map[string]any) | |
| 47 | + | if !ok { | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | cef, ok := engine["cef"].(map[string]any) | |
| 52 | + | if !ok { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | delete(cef, "enable_context_menu_handler") | |
| 57 | + | } | |
| 58 | + | ||
| 44 | 59 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |
| 45 | 60 | // Navigate to the parent of the actions section | |
| 46 | 61 | current := rawConfig | |
M internal/infrastructure/config/transform_legacy_test.go +63 -0
| @@ -170,6 +170,69 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||
| 170 | 170 | require.NotNil(t, rawConfig["workspace"]) | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | + | func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *testing.T) { | |
| 174 | + | transformer := NewLegacyConfigTransformer() | |
| 175 | + | ||
| 176 | + | rawConfig := map[string]any{ | |
| 177 | + | "engine": map[string]any{ | |
| 178 | + | "cef": map[string]any{ | |
| 179 | + | "enable_context_menu_handler": true, | |
| 180 | + | }, | |
| 181 | + | }, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 185 | + | ||
| 186 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 187 | + | _, exists := cef["enable_context_menu_handler"] | |
| 188 | + | assert.False(t, exists) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | |
| 192 | + | transformer := NewLegacyConfigTransformer() | |
| 193 | + | rawConfig := map[string]any{ | |
| 194 | + | "engine": map[string]any{ | |
| 195 | + | "webkit": map[string]any{"enabled": true}, | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 200 | + | ||
| 201 | + | engine, ok := rawConfig["engine"].(map[string]any) | |
| 202 | + | require.True(t, ok) | |
| 203 | + | assert.NotNil(t, engine) | |
| 204 | + | assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"]) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) { | |
| 208 | + | transformer := NewLegacyConfigTransformer() | |
| 209 | + | rawConfig := map[string]any{ | |
| 210 | + | "engine": map[string]any{ | |
| 211 | + | "cef": map[string]any{ | |
| 212 | + | "other_setting": true, | |
| 213 | + | }, | |
| 214 | + | }, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 218 | + | ||
| 219 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 220 | + | assert.True(t, cef["other_setting"].(bool)) | |
| 221 | + | _, exists := cef["enable_context_menu_handler"] | |
| 222 | + | assert.False(t, exists) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) { | |
| 226 | + | transformer := NewLegacyConfigTransformer() | |
| 227 | + | rawConfig := map[string]any{ | |
| 228 | + | "engine": "invalid", | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 232 | + | ||
| 233 | + | assert.Equal(t, "invalid", rawConfig["engine"]) | |
| 234 | + | } | |
| 235 | + | ||
| 173 | 236 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |
| 174 | 237 | transformer := NewLegacyConfigTransformer() | |
| 175 | 238 | ||
M internal/infrastructure/config/validation.go +7 -3
| @@ -388,18 +388,22 @@ func validateWorkspaceURLValue(fieldPath, value string) []string { | |||
| 388 | 388 | } | |
| 389 | 389 | ||
| 390 | 390 | func validateOmnibox(config *Config) []string { | |
| 391 | + | var validationErrors []string | |
| 392 | + | if config.Omnibox.MostVisitedDays < 0 { | |
| 393 | + | validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative") | |
| 394 | + | } | |
| 391 | 395 | switch config.Omnibox.InitialBehavior { | |
| 392 | 396 | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: | |
| 393 | - | return nil | |
| 394 | 397 | default: | |
| 395 | - | return []string{fmt.Sprintf( | |
| 398 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 396 | 399 | "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)", | |
| 397 | 400 | OmniboxInitialBehaviorRecent, | |
| 398 | 401 | OmniboxInitialBehaviorMostVisited, | |
| 399 | 402 | OmniboxInitialBehaviorNone, | |
| 400 | 403 | config.Omnibox.InitialBehavior, | |
| 401 | - | )} | |
| 404 | + | )) | |
| 402 | 405 | } | |
| 406 | + | return validationErrors | |
| 403 | 407 | } | |
| 404 | 408 | ||
| 405 | 409 | func validateRendering(config *Config) []string { | |
M internal/infrastructure/config/xdg.go +39 -25
| @@ -4,6 +4,8 @@ package config | |||
| 4 | 4 | import ( | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // File permission constants | |
| @@ -25,25 +27,42 @@ type XDGDirs struct { | |||
| 25 | 27 | CacheHome string | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | - | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |
| 29 | - | // It follows the XDG Base Directory specification: | |
| 30 | - | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | |
| 31 | - | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |
| 32 | - | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |
| 30 | + | // GetXDGDirs returns the dumber-specific XDG directories. | |
| 31 | + | // In prod they match the current XDG layout exactly. | |
| 32 | + | // In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}. | |
| 33 | 33 | func GetXDGDirs() (*XDGDirs, error) { | |
| 34 | - | // Development mode: use .dev directory in current working directory | |
| 34 | + | profile, err := resolveSharedProfile() | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | return &XDGDirs{ | |
| 39 | + | ConfigHome: profile.Shared.ConfigDir, | |
| 40 | + | DataHome: profile.Shared.DataDir, | |
| 41 | + | StateHome: profile.Shared.StateDir, | |
| 42 | + | CacheHome: profile.Shared.CacheDir, | |
| 43 | + | }, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func resolveSharedProfile() (runtimeprofile.Profile, error) { | |
| 47 | + | base, err := getBaseXDGDirs() | |
| 48 | + | if err != nil { | |
| 49 | + | return runtimeprofile.Profile{}, err | |
| 50 | + | } | |
| 51 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 52 | + | Env: os.Getenv, | |
| 53 | + | CWD: os.Getwd, | |
| 54 | + | Base: runtimeprofile.BasePaths{ | |
| 55 | + | ConfigHome: base.ConfigHome, | |
| 56 | + | DataHome: base.DataHome, | |
| 57 | + | StateHome: base.StateHome, | |
| 58 | + | CacheHome: base.CacheHome, | |
| 59 | + | }, | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func getBaseXDGDirs() (*XDGDirs, error) { | |
| 35 | 64 | if os.Getenv("ENV") == "dev" { | |
| 36 | - | cwd, err := os.Getwd() | |
| 37 | - | if err != nil { | |
| 38 | - | return nil, err | |
| 39 | - | } | |
| 40 | - | devDir := filepath.Join(cwd, ".dev", appName) | |
| 41 | - | return &XDGDirs{ | |
| 42 | - | ConfigHome: devDir, | |
| 43 | - | DataHome: devDir, | |
| 44 | - | StateHome: devDir, | |
| 45 | - | CacheHome: devDir, | |
| 46 | - | }, nil | |
| 65 | + | return &XDGDirs{}, nil | |
| 47 | 66 | } | |
| 48 | 67 | ||
| 49 | 68 | homeDir, err := os.UserHomeDir() | |
| @@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) { | |||
| 51 | 70 | return nil, err | |
| 52 | 71 | } | |
| 53 | 72 | ||
| 54 | - | // XDG_CONFIG_HOME | |
| 55 | 73 | configHome := os.Getenv("XDG_CONFIG_HOME") | |
| 56 | 74 | if configHome == "" { | |
| 57 | 75 | configHome = filepath.Join(homeDir, ".config") | |
| 58 | 76 | } | |
| 59 | 77 | configHome = filepath.Join(configHome, appName) | |
| 60 | 78 | ||
| 61 | - | // XDG_DATA_HOME | |
| 62 | 79 | dataHome := os.Getenv("XDG_DATA_HOME") | |
| 63 | 80 | if dataHome == "" { | |
| 64 | 81 | dataHome = filepath.Join(homeDir, ".local", "share") | |
| 65 | 82 | } | |
| 66 | 83 | dataHome = filepath.Join(dataHome, appName) | |
| 67 | 84 | ||
| 68 | - | // XDG_STATE_HOME | |
| 69 | 85 | stateHome := os.Getenv("XDG_STATE_HOME") | |
| 70 | 86 | if stateHome == "" { | |
| 71 | 87 | stateHome = filepath.Join(homeDir, ".local", "state") | |
| 72 | 88 | } | |
| 73 | 89 | stateHome = filepath.Join(stateHome, appName) | |
| 74 | 90 | ||
| 75 | - | // XDG_CACHE_HOME | |
| 76 | 91 | cacheHome := os.Getenv("XDG_CACHE_HOME") | |
| 77 | 92 | if cacheHome == "" { | |
| 78 | 93 | cacheHome = filepath.Join(homeDir, ".cache") | |
| @@ -114,14 +129,13 @@ func GetStateDir() (string, error) { | |||
| 114 | 129 | return dirs.StateHome, nil | |
| 115 | 130 | } | |
| 116 | 131 | ||
| 117 | - | // GetLogDir returns the XDG-compliant log directory for dumber. | |
| 118 | - | // Logs are stored in XDG_STATE_HOME as per specification. | |
| 132 | + | // GetLogDir returns the shared application log directory for dumber. | |
| 119 | 133 | func GetLogDir() (string, error) { | |
| 120 | - | stateDir, err := GetStateDir() | |
| 134 | + | profile, err := resolveSharedProfile() | |
| 121 | 135 | if err != nil { | |
| 122 | 136 | return "", err | |
| 123 | 137 | } | |
| 124 | - | return filepath.Join(stateDir, "logs"), nil | |
| 138 | + | return profile.Shared.LogDir, nil | |
| 125 | 139 | } | |
| 126 | 140 | ||
| 127 | 141 | // GetConfigFile returns the path to the main configuration file. | |
A internal/infrastructure/config/xdg_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) { | |
| 11 | + | home := t.TempDir() | |
| 12 | + | t.Setenv("ENV", "") | |
| 13 | + | t.Setenv("HOME", home) | |
| 14 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 15 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 16 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 17 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 18 | + | ||
| 19 | + | dirs, err := GetXDGDirs() | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome) | |
| 22 | + | require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome) | |
| 23 | + | require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome) | |
| 24 | + | require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) { | |
| 28 | + | wd := t.TempDir() | |
| 29 | + | t.Chdir(wd) | |
| 30 | + | ||
| 31 | + | t.Setenv("ENV", "dev") | |
| 32 | + | dirs, err := GetXDGDirs() | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome) | |
| 35 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome) | |
| 36 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome) | |
| 37 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome) | |
| 38 | + | ||
| 39 | + | logDir, err := GetLogDir() | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir) | |
| 42 | + | } | |
M internal/infrastructure/desktop/adapter.go +52 -7
| @@ -381,12 +381,13 @@ func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error { | |||
| 381 | 381 | ||
| 382 | 382 | // SessionSpawner implements port.SessionSpawner by launching a new dumber process. | |
| 383 | 383 | type SessionSpawner struct { | |
| 384 | - | ctx context.Context | |
| 384 | + | ctx context.Context | |
| 385 | + | spawnEnv port.SessionSpawnEnvironment | |
| 385 | 386 | } | |
| 386 | 387 | ||
| 387 | 388 | // NewSessionSpawner creates a new session spawner. | |
| 388 | - | func NewSessionSpawner(ctx context.Context) *SessionSpawner { | |
| 389 | - | return &SessionSpawner{ctx: ctx} | |
| 389 | + | func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironment) *SessionSpawner { | |
| 390 | + | return &SessionSpawner{ctx: ctx, spawnEnv: spawnEnv} | |
| 390 | 391 | } | |
| 391 | 392 | ||
| 392 | 393 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| @@ -406,7 +407,18 @@ func LaunchExternalURL(uri string) { | |||
| 406 | 407 | ||
| 407 | 408 | // LaunchBrowserURL opens a URL in a new dumber browser window. | |
| 408 | 409 | func LaunchBrowserURL(uri string) { | |
| 409 | - | execPath, err := getExecutablePath() | |
| 410 | + | launchBrowserBrowseURL(uri, getExecutablePath, startDetachedProcess) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) { | |
| 414 | + | if resolveExecutablePath == nil { | |
| 415 | + | resolveExecutablePath = getExecutablePath | |
| 416 | + | } | |
| 417 | + | if spawnDetachedProcess == nil { | |
| 418 | + | spawnDetachedProcess = startDetachedProcess | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | execPath, err := resolveExecutablePath() | |
| 410 | 422 | if err != nil { | |
| 411 | 423 | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err) | |
| 412 | 424 | return | |
| @@ -414,7 +426,7 @@ func LaunchBrowserURL(uri string) { | |||
| 414 | 426 | ||
| 415 | 427 | cmd := exec.Command(execPath, "browse", uri) | |
| 416 | 428 | cmd.Env = sanitizedChildEnv(os.Environ()) | |
| 417 | - | if err := startDetachedProcess(cmd); err != nil { | |
| 429 | + | if err := spawnDetachedProcess(cmd); err != nil { | |
| 418 | 430 | fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err) | |
| 419 | 431 | } | |
| 420 | 432 | } | |
| @@ -469,6 +481,33 @@ func sanitizedChildEnv(env []string) []string { | |||
| 469 | 481 | return cleaned | |
| 470 | 482 | } | |
| 471 | 483 | ||
| 484 | + | func withoutEnvKeys(env []string, keys ...string) []string { | |
| 485 | + | if len(keys) == 0 { | |
| 486 | + | return append([]string(nil), env...) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | blocked := make(map[string]struct{}, len(keys)) | |
| 490 | + | for _, key := range keys { | |
| 491 | + | if key == "" { | |
| 492 | + | continue | |
| 493 | + | } | |
| 494 | + | blocked[key] = struct{}{} | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | filtered := make([]string, 0, len(env)) | |
| 498 | + | for _, entry := range env { | |
| 499 | + | key, _, ok := strings.Cut(entry, "=") | |
| 500 | + | if ok { | |
| 501 | + | if _, drop := blocked[key]; drop { | |
| 502 | + | continue | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | filtered = append(filtered, entry) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | return filtered | |
| 509 | + | } | |
| 510 | + | ||
| 472 | 511 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 473 | 512 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 474 | 513 | log := logging.FromContext(s.ctx) | |
| @@ -481,8 +520,14 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 481 | 520 | // Start dumber browse with session ID in environment | |
| 482 | 521 | cmd := exec.Command(execPath, "browse") | |
| 483 | 522 | ||
| 484 | - | // Inherit environment and add restore session ID | |
| 485 | - | cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID)) | |
| 523 | + | sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar) | |
| 524 | + | overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)} | |
| 525 | + | if s.spawnEnv != nil { | |
| 526 | + | sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar()) | |
| 527 | + | overrides = append(overrides, s.spawnEnv.RootCacheEnvVar()+"="+s.spawnEnv.SessionRootCachePath(sessionID)) | |
| 528 | + | } | |
| 529 | + | sanitizedEnv = append(sanitizedEnv, overrides...) | |
| 530 | + | cmd.Env = sanitizedEnv | |
| 486 | 531 | ||
| 487 | 532 | // Detach from current process group so the new process survives. | |
| 488 | 533 | cmd.Stdin = nil | |
M internal/infrastructure/desktop/adapter_test.go +149 -1
| @@ -1,6 +1,70 @@ | |||
| 1 | 1 | package desktop | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE" | |
| 14 | + | ||
| 15 | + | const testRootCacheEnvVar = "DUMBER_TEST_ROOT_CACHE_PATH" | |
| 16 | + | ||
| 17 | + | type testSessionSpawnEnvironment struct { | |
| 18 | + | rootDir string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (testSessionSpawnEnvironment) RootCacheEnvVar() string { | |
| 22 | + | return testRootCacheEnvVar | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (e testSessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 26 | + | return filepath.Join(e.rootDir, "sessions", string(sessionID)) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestMain(m *testing.M) { | |
| 30 | + | if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" { | |
| 31 | + | if err := writeFileAtomically(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil { | |
| 32 | + | os.Exit(2) | |
| 33 | + | } | |
| 34 | + | os.Exit(0) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | os.Exit(m.Run()) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func writeFileAtomically(path string, data []byte, perm os.FileMode) error { | |
| 41 | + | tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") | |
| 42 | + | if err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | tmpPath := tmpFile.Name() | |
| 47 | + | defer func() { | |
| 48 | + | _ = os.Remove(tmpPath) | |
| 49 | + | }() | |
| 50 | + | ||
| 51 | + | if _, err := tmpFile.Write(data); err != nil { | |
| 52 | + | _ = tmpFile.Close() | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | if err := tmpFile.Chmod(perm); err != nil { | |
| 56 | + | _ = tmpFile.Close() | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | if err := tmpFile.Close(); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | if err := os.Rename(tmpPath, path); err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | return nil | |
| 67 | + | } | |
| 4 | 68 | ||
| 5 | 69 | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |
| 6 | 70 | env := sanitizedChildEnv([]string{ | |
| @@ -32,3 +96,87 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |||
| 32 | 96 | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |
| 33 | 97 | } | |
| 34 | 98 | } | |
| 99 | + | ||
| 100 | + | func TestWithoutEnvKeys_RemovesExplicitOverrides(t *testing.T) { | |
| 101 | + | env := withoutEnvKeys([]string{ | |
| 102 | + | "PATH=/usr/bin", | |
| 103 | + | RestoreSessionEnvVar + "=old-session", | |
| 104 | + | testRootCacheEnvVar + "=/tmp/old-root", | |
| 105 | + | }, RestoreSessionEnvVar, testRootCacheEnvVar) | |
| 106 | + | ||
| 107 | + | if len(env) != 1 || env[0] != "PATH=/usr/bin" { | |
| 108 | + | t.Fatalf("expected override keys to be removed, got %#v", env) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndEngineOverride(t *testing.T) { | |
| 113 | + | rootDir := filepath.Join(t.TempDir(), "cef-root") | |
| 114 | + | spawner := NewSessionSpawner(t.Context(), testSessionSpawnEnvironment{rootDir: rootDir}) | |
| 115 | + | childEnvFile := filepath.Join(t.TempDir(), "child-env.txt") | |
| 116 | + | t.Setenv(testCaptureEnvFile, childEnvFile) | |
| 117 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data")) | |
| 118 | + | t.Setenv("ENV", "") | |
| 119 | + | ||
| 120 | + | sessionID := entity.SessionID("session-123") | |
| 121 | + | requireNoError(t, spawner.SpawnWithSession(sessionID)) | |
| 122 | + | ||
| 123 | + | env := readEnvFile(t, childEnvFile) | |
| 124 | + | wantRoot := filepath.Join(rootDir, "sessions", string(sessionID)) | |
| 125 | + | if got := env[RestoreSessionEnvVar]; got != string(sessionID) { | |
| 126 | + | t.Fatalf("restore session env = %q, want %q", got, string(sessionID)) | |
| 127 | + | } | |
| 128 | + | if got := env[testRootCacheEnvVar]; got != wantRoot { | |
| 129 | + | t.Fatalf("engine state root env = %q, want %q", got, wantRoot) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func readEnvFile(t *testing.T, path string) map[string]string { | |
| 134 | + | t.Helper() | |
| 135 | + | ||
| 136 | + | deadline := time.Now().Add(2 * time.Second) | |
| 137 | + | for time.Now().Before(deadline) { | |
| 138 | + | data, err := os.ReadFile(path) | |
| 139 | + | if err == nil { | |
| 140 | + | env := make(map[string]string) | |
| 141 | + | for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { | |
| 142 | + | if line == "" { | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | key, value, ok := strings.Cut(line, "=") | |
| 146 | + | if ok { | |
| 147 | + | env[key] = value | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return env | |
| 151 | + | } | |
| 152 | + | time.Sleep(10 * time.Millisecond) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | t.Fatalf("timed out waiting for child env file %q", path) | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestSessionSpawner_SpawnWithSession_OmitsEngineOverrideWhenNotConfigured(t *testing.T) { | |
| 160 | + | spawner := NewSessionSpawner(t.Context(), nil) | |
| 161 | + | childEnvFile := filepath.Join(t.TempDir(), "child-env.txt") | |
| 162 | + | t.Setenv(testCaptureEnvFile, childEnvFile) | |
| 163 | + | t.Setenv("ENV", "") | |
| 164 | + | ||
| 165 | + | sessionID := entity.SessionID("session-plain") | |
| 166 | + | requireNoError(t, spawner.SpawnWithSession(sessionID)) | |
| 167 | + | ||
| 168 | + | env := readEnvFile(t, childEnvFile) | |
| 169 | + | if got := env[RestoreSessionEnvVar]; got != string(sessionID) { | |
| 170 | + | t.Fatalf("restore session env = %q, want %q", got, string(sessionID)) | |
| 171 | + | } | |
| 172 | + | if got := env[testRootCacheEnvVar]; got != "" { | |
| 173 | + | t.Fatalf("engine state root env = %q, want empty", got) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func requireNoError(t *testing.T, err error) { | |
| 178 | + | t.Helper() | |
| 179 | + | if err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | } | |
A internal/infrastructure/desktop/browser_launch_relay.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sync" | |
| 13 | + | "syscall" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const browserLaunchSocketName = "browser-launch.sock" | |
| 22 | + | ||
| 23 | + | const browserLaunchIOTimeout = 50 * time.Millisecond | |
| 24 | + | ||
| 25 | + | const browserLaunchDirPerm = 0o700 | |
| 26 | + | ||
| 27 | + | type browserLaunchRelay struct { | |
| 28 | + | ipc runtimeprofile.IPCPaths | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type browserLaunchRequest struct { | |
| 32 | + | URL string `json:"url"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type browserLaunchResponse struct { | |
| 36 | + | Error string `json:"error,omitempty"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type browserLaunchRelayListener struct { | |
| 40 | + | listener *net.UnixListener | |
| 41 | + | socketPath string | |
| 42 | + | once sync.Once | |
| 43 | + | err error | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay { | |
| 47 | + | return &browserLaunchRelay{ipc: ipc} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | |
| 51 | + | socketPath, err := r.socketPath() | |
| 52 | + | if err != nil { | |
| 53 | + | return false, err | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath) | |
| 57 | + | if err != nil { | |
| 58 | + | if isMissingRelayListener(err) { | |
| 59 | + | return false, nil | |
| 60 | + | } | |
| 61 | + | return false, err | |
| 62 | + | } | |
| 63 | + | defer func() { _ = conn.Close() }() | |
| 64 | + | ||
| 65 | + | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 66 | + | return false, err | |
| 67 | + | } | |
| 68 | + | if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil { | |
| 69 | + | return false, err | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 73 | + | return false, err | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | var response browserLaunchResponse | |
| 77 | + | decoder := json.NewDecoder(conn) | |
| 78 | + | for { | |
| 79 | + | if decodeErr := decoder.Decode(&response); decodeErr != nil { | |
| 80 | + | if isBrowserLaunchReadTimeout(decodeErr) { | |
| 81 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 82 | + | return false, ctxErr | |
| 83 | + | } | |
| 84 | + | if _, ok := ctx.Deadline(); !ok { | |
| 85 | + | return true, nil | |
| 86 | + | } | |
| 87 | + | if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil { | |
| 88 | + | return false, deadlineErr | |
| 89 | + | } | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | return false, decodeErr | |
| 93 | + | } | |
| 94 | + | break | |
| 95 | + | } | |
| 96 | + | if response.Error != "" { | |
| 97 | + | return true, errors.New(response.Error) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return true, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func isMissingRelayListener(err error) bool { | |
| 104 | + | return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func isBrowserLaunchReadTimeout(err error) bool { | |
| 108 | + | var netErr net.Error | |
| 109 | + | return errors.As(err, &netErr) && netErr.Timeout() | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) { | |
| 113 | + | conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout) | |
| 114 | + | if err == nil { | |
| 115 | + | _ = conn.Close() | |
| 116 | + | return true, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | if isMissingRelayListener(err) { | |
| 120 | + | return false, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | return false, fmt.Errorf("check browser launch socket: %w", err) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error { | |
| 127 | + | deadline := time.Now().Add(browserLaunchIOTimeout) | |
| 128 | + | if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { | |
| 129 | + | deadline = ctxDeadline | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | return conn.SetDeadline(deadline) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | |
| 136 | + | socketPath, err := r.socketPath() | |
| 137 | + | if err != nil { | |
| 138 | + | return nil, err | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil { | |
| 142 | + | return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr) | |
| 143 | + | } | |
| 144 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 145 | + | if err != nil { | |
| 146 | + | if !errors.Is(err, syscall.EADDRINUSE) { | |
| 147 | + | return nil, fmt.Errorf("listen browser launch socket: %w", err) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | live, liveErr := browserLaunchSocketHasLiveListener(socketPath) | |
| 151 | + | if liveErr != nil { | |
| 152 | + | return nil, liveErr | |
| 153 | + | } | |
| 154 | + | if live { | |
| 155 | + | return nil, errors.New("browser launch relay already running") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { | |
| 159 | + | return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 163 | + | if err != nil { | |
| 164 | + | return nil, fmt.Errorf("listen browser launch socket: %w", err) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath} | |
| 169 | + | go relayListener.serve(ctx, opener) | |
| 170 | + | ||
| 171 | + | return relayListener, nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (r *browserLaunchRelay) socketPath() (string, error) { | |
| 175 | + | if r == nil { | |
| 176 | + | return "", errors.New("browser launch relay missing IPC paths") | |
| 177 | + | } | |
| 178 | + | if r.ipc.BrowserLaunchSocket == "" { | |
| 179 | + | return "", errors.New("browser launch relay missing browser launch socket path") | |
| 180 | + | } | |
| 181 | + | return r.ipc.BrowserLaunchSocket, nil | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (l *browserLaunchRelayListener) Close() error { | |
| 185 | + | l.once.Do(func() { | |
| 186 | + | if l.listener != nil { | |
| 187 | + | l.err = l.listener.Close() | |
| 188 | + | } | |
| 189 | + | _ = os.Remove(l.socketPath) | |
| 190 | + | }) | |
| 191 | + | ||
| 192 | + | return l.err | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) { | |
| 196 | + | defer func() { _ = l.Close() }() | |
| 197 | + | ||
| 198 | + | for { | |
| 199 | + | if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | conn, err := l.listener.AcceptUnix() | |
| 203 | + | if err != nil { | |
| 204 | + | if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | var netErr net.Error | |
| 208 | + | if errors.As(err, &netErr) && netErr.Timeout() { | |
| 209 | + | if ctx.Err() != nil { | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | continue | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | go l.handleConnection(ctx, conn, opener) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) { | |
| 222 | + | defer func() { _ = conn.Close() }() | |
| 223 | + | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | var request browserLaunchRequest | |
| 228 | + | if err := json.NewDecoder(conn).Decode(&request); err != nil { | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil { | |
| 236 | + | log := logging.FromContext(ctx) | |
| 237 | + | log.Warn().Err(err).Str("url", request.URL).Msg("failed to encode browser launch response") | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | go func() { | |
| 242 | + | if err := opener.OpenFreshWindow(ctx, request.URL); err != nil { | |
| 243 | + | logging.FromContext(ctx).Warn().Err(err).Str("url", request.URL).Msg("failed to open fresh browser window") | |
| 244 | + | } | |
| 245 | + | }() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil) | |
A internal/infrastructure/desktop/browser_launch_relay_test.go +447 -0
| @@ -0,0 +1,447 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type browserWindowOpenerFunc func(context.Context, string) error | |
| 18 | + | ||
| 19 | + | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | |
| 20 | + | return f(ctx, url) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func waitForSocket(t *testing.T, path string) { | |
| 24 | + | t.Helper() | |
| 25 | + | ||
| 26 | + | require.Eventually(t, func() bool { | |
| 27 | + | _, err := os.Stat(path) | |
| 28 | + | return err == nil | |
| 29 | + | }, time.Second, 10*time.Millisecond) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func waitForSocketGone(t *testing.T, path string) { | |
| 33 | + | t.Helper() | |
| 34 | + | ||
| 35 | + | require.Eventually(t, func() bool { | |
| 36 | + | _, err := os.Stat(path) | |
| 37 | + | return os.IsNotExist(err) | |
| 38 | + | }, time.Second, 10*time.Millisecond) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func shortTempDir(t *testing.T) string { | |
| 42 | + | t.Helper() | |
| 43 | + | ||
| 44 | + | dir, err := os.MkdirTemp("", "dbr-") | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | t.Cleanup(func() { | |
| 47 | + | _ = os.RemoveAll(dir) | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | return dir | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func testIPC(root string) runtimeprofile.IPCPaths { | |
| 54 | + | runtimeDir := filepath.Join(root, "runtime") | |
| 55 | + | return runtimeprofile.IPCPaths{ | |
| 56 | + | RuntimeDir: runtimeDir, | |
| 57 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths { | |
| 62 | + | runtimeDir := filepath.Join(root, "runtime", engine) | |
| 63 | + | return runtimeprofile.IPCPaths{ | |
| 64 | + | RuntimeDir: runtimeDir, | |
| 65 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName), | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | |
| 70 | + | relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t))) | |
| 71 | + | ||
| 72 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | |
| 73 | + | ||
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.False(t, delivered) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { | |
| 79 | + | ipc := testNamespacedIPC(shortTempDir(t), "cef") | |
| 80 | + | listenerRelay := NewBrowserLaunchRelay(ipc) | |
| 81 | + | clientRelay := NewBrowserLaunchRelay(ipc) | |
| 82 | + | ||
| 83 | + | received := make(chan string, 1) | |
| 84 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 85 | + | defer cancel() | |
| 86 | + | ||
| 87 | + | closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 88 | + | received <- url | |
| 89 | + | return nil | |
| 90 | + | })) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | defer closer.Close() | |
| 93 | + | ||
| 94 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 95 | + | ||
| 96 | + | delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace") | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | require.True(t, delivered) | |
| 99 | + | require.Equal(t, "https://example.com/same-namespace", <-received) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) { | |
| 103 | + | root := t.TempDir() | |
| 104 | + | cefIPC := testNamespacedIPC(root, "cef") | |
| 105 | + | wkIPC := testNamespacedIPC(root, "webkit") | |
| 106 | + | require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |
| 110 | + | ipc := testIPC(shortTempDir(t)) | |
| 111 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 112 | + | ||
| 113 | + | received := make(chan string, 1) | |
| 114 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 115 | + | defer cancel() | |
| 116 | + | ||
| 117 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 118 | + | received <- url | |
| 119 | + | return nil | |
| 120 | + | })) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | defer closer.Close() | |
| 123 | + | ||
| 124 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 125 | + | ||
| 126 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |
| 127 | + | ||
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.True(t, delivered) | |
| 130 | + | ||
| 131 | + | select { | |
| 132 | + | case got := <-received: | |
| 133 | + | assert.Equal(t, "https://example.com/new", got) | |
| 134 | + | case <-time.After(time.Second): | |
| 135 | + | t.Fatal("expected opener to receive the URL") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) { | |
| 140 | + | relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{}) | |
| 141 | + | ||
| 142 | + | _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 143 | + | require.Error(t, err) | |
| 144 | + | require.Contains(t, err.Error(), "browser launch socket path") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | |
| 148 | + | ipc := testIPC(shortTempDir(t)) | |
| 149 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 150 | + | ||
| 151 | + | firstReceived := make(chan string, 1) | |
| 152 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 153 | + | defer cancel() | |
| 154 | + | ||
| 155 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 156 | + | firstReceived <- url | |
| 157 | + | return nil | |
| 158 | + | })) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | defer closer.Close() | |
| 161 | + | ||
| 162 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 163 | + | ||
| 164 | + | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 165 | + | require.Error(t, err) | |
| 166 | + | ||
| 167 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live") | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | require.True(t, delivered) | |
| 170 | + | ||
| 171 | + | select { | |
| 172 | + | case got := <-firstReceived: | |
| 173 | + | assert.Equal(t, "https://example.com/live", got) | |
| 174 | + | case <-time.After(time.Second): | |
| 175 | + | t.Fatal("expected the original listener to remain active") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |
| 180 | + | ipc := testIPC(shortTempDir(t)) | |
| 181 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 182 | + | ||
| 183 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 184 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) | |
| 185 | + | ||
| 186 | + | received := make(chan string, 1) | |
| 187 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 188 | + | defer cancel() | |
| 189 | + | ||
| 190 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 191 | + | received <- url | |
| 192 | + | return nil | |
| 193 | + | })) | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | defer closer.Close() | |
| 196 | + | ||
| 197 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 198 | + | ||
| 199 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | require.True(t, delivered) | |
| 202 | + | ||
| 203 | + | select { | |
| 204 | + | case got := <-received: | |
| 205 | + | assert.Equal(t, "https://example.com/stale", got) | |
| 206 | + | case <-time.After(time.Second): | |
| 207 | + | t.Fatal("expected stale socket path to be rebound") | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |
| 212 | + | ipc := testIPC(shortTempDir(t)) | |
| 213 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 214 | + | ||
| 215 | + | received := make(chan string, 1) | |
| 216 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 217 | + | defer cancel() | |
| 218 | + | ||
| 219 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 220 | + | received <- url | |
| 221 | + | return nil | |
| 222 | + | })) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | defer closer.Close() | |
| 225 | + | ||
| 226 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 227 | + | ||
| 228 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | ||
| 231 | + | _, err = conn.Write([]byte("not-json")) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | require.NoError(t, conn.Close()) | |
| 234 | + | ||
| 235 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works") | |
| 236 | + | ||
| 237 | + | require.NoError(t, err) | |
| 238 | + | assert.True(t, delivered) | |
| 239 | + | ||
| 240 | + | select { | |
| 241 | + | case got := <-received: | |
| 242 | + | assert.Equal(t, "https://example.com/still-works", got) | |
| 243 | + | case <-time.After(time.Second): | |
| 244 | + | t.Fatal("expected opener to receive the URL after malformed payload") | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |
| 249 | + | ipc := testIPC(shortTempDir(t)) | |
| 250 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 251 | + | ||
| 252 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 253 | + | defer cancel() | |
| 254 | + | ||
| 255 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | ||
| 258 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 259 | + | ||
| 260 | + | require.NoError(t, closer.Close()) | |
| 261 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |
| 265 | + | ipc := testIPC(shortTempDir(t)) | |
| 266 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 267 | + | ||
| 268 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 269 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 270 | + | require.NoError(t, err) | |
| 271 | + | ||
| 272 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 273 | + | ||
| 274 | + | cancel() | |
| 275 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 276 | + | require.NoError(t, closer.Close()) | |
| 277 | + | ||
| 278 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | assert.False(t, delivered) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | |
| 284 | + | ipc := testIPC(shortTempDir(t)) | |
| 285 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 286 | + | ||
| 287 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | defer func() { | |
| 290 | + | _ = listener.Close() | |
| 291 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 292 | + | }() | |
| 293 | + | ||
| 294 | + | ready := make(chan struct{}) | |
| 295 | + | go func() { | |
| 296 | + | close(ready) | |
| 297 | + | conn, acceptErr := listener.AcceptUnix() | |
| 298 | + | if acceptErr != nil { | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | defer conn.Close() | |
| 302 | + | <-time.After(time.Second) | |
| 303 | + | }() | |
| 304 | + | <-ready | |
| 305 | + | ||
| 306 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 307 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 308 | + | defer cancel() | |
| 309 | + | ||
| 310 | + | delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow") | |
| 311 | + | require.Error(t, err) | |
| 312 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 313 | + | assert.False(t, delivered) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | |
| 317 | + | ipc := testIPC(shortTempDir(t)) | |
| 318 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 319 | + | ||
| 320 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | defer func() { | |
| 323 | + | _ = listener.Close() | |
| 324 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 325 | + | }() | |
| 326 | + | ||
| 327 | + | ready := make(chan struct{}) | |
| 328 | + | go func() { | |
| 329 | + | close(ready) | |
| 330 | + | conn, acceptErr := listener.AcceptUnix() | |
| 331 | + | if acceptErr != nil { | |
| 332 | + | return | |
| 333 | + | } | |
| 334 | + | defer conn.Close() | |
| 335 | + | <-time.After(time.Second) | |
| 336 | + | }() | |
| 337 | + | <-ready | |
| 338 | + | ||
| 339 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 340 | + | ||
| 341 | + | started := make(chan struct{}) | |
| 342 | + | ||
| 343 | + | type deliverResult struct { | |
| 344 | + | delivered bool | |
| 345 | + | err error | |
| 346 | + | } | |
| 347 | + | result := make(chan deliverResult, 1) | |
| 348 | + | go func() { | |
| 349 | + | close(started) | |
| 350 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow") | |
| 351 | + | result <- deliverResult{delivered: delivered, err: err} | |
| 352 | + | }() | |
| 353 | + | <-started | |
| 354 | + | ||
| 355 | + | select { | |
| 356 | + | case got := <-result: | |
| 357 | + | assert.True(t, got.delivered) | |
| 358 | + | require.NoError(t, got.err) | |
| 359 | + | case <-time.After(300 * time.Millisecond): | |
| 360 | + | t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline") | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |
| 365 | + | ipc := testIPC(shortTempDir(t)) | |
| 366 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 367 | + | ||
| 368 | + | started := make(chan struct{}) | |
| 369 | + | release := make(chan struct{}) | |
| 370 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 371 | + | defer cancel() | |
| 372 | + | ||
| 373 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error { | |
| 374 | + | close(started) | |
| 375 | + | <-release | |
| 376 | + | return nil | |
| 377 | + | })) | |
| 378 | + | require.NoError(t, err) | |
| 379 | + | defer closer.Close() | |
| 380 | + | ||
| 381 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 382 | + | ||
| 383 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 384 | + | require.NoError(t, err) | |
| 385 | + | defer conn.Close() | |
| 386 | + | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | |
| 387 | + | require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{URL: "https://example.com/slow-but-healthy"})) | |
| 388 | + | ||
| 389 | + | var response browserLaunchResponse | |
| 390 | + | require.NoError(t, json.NewDecoder(conn).Decode(&response)) | |
| 391 | + | assert.Empty(t, response.Error) | |
| 392 | + | ||
| 393 | + | select { | |
| 394 | + | case <-started: | |
| 395 | + | case <-time.After(time.Second): | |
| 396 | + | t.Fatal("expected opener to be invoked after acknowledgement") | |
| 397 | + | } | |
| 398 | + | close(release) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |
| 402 | + | ipc := testIPC(shortTempDir(t)) | |
| 403 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 404 | + | ||
| 405 | + | received := make(chan string, 1) | |
| 406 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 407 | + | defer cancel() | |
| 408 | + | ||
| 409 | + | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 410 | + | received <- url | |
| 411 | + | return nil | |
| 412 | + | })) | |
| 413 | + | require.NoError(t, err) | |
| 414 | + | defer closer.Close() | |
| 415 | + | ||
| 416 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 417 | + | ||
| 418 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 419 | + | require.NoError(t, err) | |
| 420 | + | defer conn.Close() | |
| 421 | + | time.Sleep(20 * time.Millisecond) | |
| 422 | + | ||
| 423 | + | type deliverResult struct { | |
| 424 | + | delivered bool | |
| 425 | + | err error | |
| 426 | + | } | |
| 427 | + | result := make(chan deliverResult, 1) | |
| 428 | + | go func() { | |
| 429 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered") | |
| 430 | + | result <- deliverResult{delivered: delivered, err: err} | |
| 431 | + | }() | |
| 432 | + | ||
| 433 | + | select { | |
| 434 | + | case got := <-result: | |
| 435 | + | require.NoError(t, got.err) | |
| 436 | + | require.True(t, got.delivered) | |
| 437 | + | case <-time.After(300 * time.Millisecond): | |
| 438 | + | t.Fatal("silent client stalled the relay listener") | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | select { | |
| 442 | + | case got := <-received: | |
| 443 | + | assert.Equal(t, "https://example.com/recovered", got) | |
| 444 | + | case <-time.After(time.Second): | |
| 445 | + | t.Fatal("expected opener to receive the URL") | |
| 446 | + | } | |
| 447 | + | } | |
A internal/infrastructure/desktop/browser_launcher.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // BrowserLauncher opens URLs in a relay-first dumber browser instance. | |
| 13 | + | type BrowserLauncher struct { | |
| 14 | + | relay port.BrowserLaunchRelay | |
| 15 | + | resolveExecutablePath func() (string, error) | |
| 16 | + | startDetachedProcess func(*exec.Cmd) error | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewBrowserLauncher creates a browser launcher that prefers relay handoff. | |
| 20 | + | func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher { | |
| 21 | + | return &BrowserLauncher{ | |
| 22 | + | relay: relay, | |
| 23 | + | resolveExecutablePath: getExecutablePath, | |
| 24 | + | startDetachedProcess: startDetachedProcess, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one. | |
| 29 | + | func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) { | |
| 30 | + | if l == nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if l.relay != nil { | |
| 35 | + | delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url) | |
| 36 | + | if err != nil { | |
| 37 | + | fmt.Fprintf(os.Stderr, "failed to forward dumber browser URL %q: %v\n", url, err) | |
| 38 | + | } | |
| 39 | + | if delivered { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | launchBrowserBrowseURL(url, l.resolveExecutablePath, l.startDetachedProcess) | |
| 45 | + | } | |
A internal/infrastructure/desktop/browser_launcher_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) { | |
| 17 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 18 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |
| 19 | + | ||
| 20 | + | launcher := NewBrowserLauncher(relay) | |
| 21 | + | spawned := false | |
| 22 | + | launcher.resolveExecutablePath = func() (string, error) { | |
| 23 | + | t.Fatal("expected executable path resolution to be skipped") | |
| 24 | + | return "", nil | |
| 25 | + | } | |
| 26 | + | launcher.startDetachedProcess = func(_ *exec.Cmd) error { | |
| 27 | + | spawned = true | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |
| 32 | + | ||
| 33 | + | assert.False(t, spawned) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) { | |
| 37 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 38 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |
| 39 | + | ||
| 40 | + | launcher := NewBrowserLauncher(relay) | |
| 41 | + | launcher.resolveExecutablePath = func() (string, error) { | |
| 42 | + | return "/usr/bin/dumber", nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | var gotCmd *exec.Cmd | |
| 46 | + | launcher.startDetachedProcess = func(cmd *exec.Cmd) error { | |
| 47 | + | gotCmd = cmd | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |
| 52 | + | ||
| 53 | + | require.NotNil(t, gotCmd) | |
| 54 | + | assert.Equal(t, "/usr/bin/dumber", gotCmd.Path) | |
| 55 | + | assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestBrowserLauncher_LaunchURL_ReportsRelayErrorWhenDelivered(t *testing.T) { | |
| 59 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 60 | + | wantErr := errors.New("relay exploded") | |
| 61 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, wantErr) | |
| 62 | + | ||
| 63 | + | launcher := NewBrowserLauncher(relay) | |
| 64 | + | launcher.resolveExecutablePath = func() (string, error) { | |
| 65 | + | t.Fatal("expected executable path resolution to be skipped") | |
| 66 | + | return "", nil | |
| 67 | + | } | |
| 68 | + | launcher.startDetachedProcess = func(_ *exec.Cmd) error { | |
| 69 | + | t.Fatal("expected launch to stop after relay delivery") | |
| 70 | + | return nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | oldStderr := os.Stderr | |
| 74 | + | r, w, err := os.Pipe() | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | os.Stderr = w | |
| 77 | + | t.Cleanup(func() { | |
| 78 | + | os.Stderr = oldStderr | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |
| 82 | + | ||
| 83 | + | require.NoError(t, w.Close()) | |
| 84 | + | output, err := io.ReadAll(r) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | assert.Contains(t, string(output), wantErr.Error()) | |
| 87 | + | } | |
A internal/infrastructure/downloadruntime/runtime.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package downloadruntime | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const dirPerm = 0o755 | |
| 15 | + | ||
| 16 | + | type codedDownloadError interface { | |
| 17 | + | DownloadErrorCode() int | |
| 18 | + | DownloadErrorReason() string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Runtime shares common download preparation and event emission logic across engines. | |
| 22 | + | type Runtime struct { | |
| 23 | + | mu sync.RWMutex | |
| 24 | + | downloadPath string | |
| 25 | + | eventHandler port.DownloadEventHandler | |
| 26 | + | preparer port.DownloadPreparer | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // New creates a Runtime. The caller must ensure preparer is non-nil; | |
| 30 | + | // ConfigureDownloads validates this before reaching here. | |
| 31 | + | func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime { | |
| 32 | + | return &Runtime{ | |
| 33 | + | downloadPath: downloadPath, | |
| 34 | + | eventHandler: eventHandler, | |
| 35 | + | preparer: preparer, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (r *Runtime) SetDownloadPath(path string) { | |
| 40 | + | r.mu.Lock() | |
| 41 | + | defer r.mu.Unlock() | |
| 42 | + | r.downloadPath = path | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (r *Runtime) ResolveDestination( | |
| 46 | + | ctx context.Context, | |
| 47 | + | suggestedFilename string, | |
| 48 | + | response port.DownloadResponse, | |
| 49 | + | ) (*port.DownloadPrepareOutput, error) { | |
| 50 | + | r.mu.RLock() | |
| 51 | + | downloadPath := r.downloadPath | |
| 52 | + | preparer := r.preparer | |
| 53 | + | r.mu.RUnlock() | |
| 54 | + | ||
| 55 | + | if preparer == nil { | |
| 56 | + | return nil, fmt.Errorf("download preparer is required") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | |
| 60 | + | return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 64 | + | SuggestedFilename: suggestedFilename, | |
| 65 | + | Response: response, | |
| 66 | + | DownloadDir: downloadPath, | |
| 67 | + | }) | |
| 68 | + | if output == nil { | |
| 69 | + | return nil, fmt.Errorf("failed to prepare download destination for %q", suggestedFilename) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return output, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) { | |
| 76 | + | if output == nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | r.mu.RLock() | |
| 81 | + | eventHandler := r.eventHandler | |
| 82 | + | r.mu.RUnlock() | |
| 83 | + | ||
| 84 | + | if eventHandler != nil { | |
| 85 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 86 | + | Type: port.DownloadEventStarted, | |
| 87 | + | Filename: output.Filename, | |
| 88 | + | Destination: output.DestinationPath, | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | logging.FromContext(ctx).Info(). | |
| 93 | + | Str("filename", output.Filename). | |
| 94 | + | Str("destination", output.DestinationPath). | |
| 95 | + | Msg("download started") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (r *Runtime) EmitProgress( | |
| 99 | + | ctx context.Context, | |
| 100 | + | filename, destination string, | |
| 101 | + | progress float64, | |
| 102 | + | bytesReceived, bytesTotal int64, | |
| 103 | + | ) { | |
| 104 | + | r.mu.RLock() | |
| 105 | + | eventHandler := r.eventHandler | |
| 106 | + | r.mu.RUnlock() | |
| 107 | + | ||
| 108 | + | if eventHandler != nil { | |
| 109 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 110 | + | Type: port.DownloadEventProgress, | |
| 111 | + | Filename: filename, | |
| 112 | + | Destination: destination, | |
| 113 | + | Progress: progress, | |
| 114 | + | BytesReceived: bytesReceived, | |
| 115 | + | BytesTotal: bytesTotal, | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | |
| 121 | + | r.mu.RLock() | |
| 122 | + | eventHandler := r.eventHandler | |
| 123 | + | r.mu.RUnlock() | |
| 124 | + | ||
| 125 | + | if eventHandler != nil { | |
| 126 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 127 | + | Type: port.DownloadEventFinished, | |
| 128 | + | Filename: filename, | |
| 129 | + | Destination: destination, | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | logging.FromContext(ctx).Info(). | |
| 134 | + | Str("filename", filename). | |
| 135 | + | Str("destination", destination). | |
| 136 | + | Msg("download finished") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) { | |
| 140 | + | r.mu.RLock() | |
| 141 | + | eventHandler := r.eventHandler | |
| 142 | + | r.mu.RUnlock() | |
| 143 | + | ||
| 144 | + | if eventHandler != nil { | |
| 145 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 146 | + | Type: port.DownloadEventFailed, | |
| 147 | + | Filename: filename, | |
| 148 | + | Destination: destination, | |
| 149 | + | Error: err, | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | log := logging.FromContext(ctx).Warn(). | |
| 154 | + | Err(err). | |
| 155 | + | Str("filename", filename). | |
| 156 | + | Str("destination", destination) | |
| 157 | + | var codedErr codedDownloadError | |
| 158 | + | if errors.As(err, &codedErr) { | |
| 159 | + | log = log. | |
| 160 | + | Int("error_code", codedErr.DownloadErrorCode()). | |
| 161 | + | Str("error_reason", codedErr.DownloadErrorReason()) | |
| 162 | + | } | |
| 163 | + | log.Msg("download failed") | |
| 164 | + | } | |
A internal/infrastructure/downloadruntime/runtime_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package downloadruntime | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type stubResponse struct { | |
| 14 | + | mimeType string | |
| 15 | + | uri string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s stubResponse) GetMimeType() string { return s.mimeType } | |
| 19 | + | func (stubResponse) GetSuggestedFilename() string { return "" } | |
| 20 | + | func (s stubResponse) GetUri() string { return s.uri } | |
| 21 | + | ||
| 22 | + | type captureEvents struct { | |
| 23 | + | events []port.DownloadEvent | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *captureEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |
| 27 | + | c.events = append(c.events, event) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRuntimeResolveDestinationAndEvents(t *testing.T) { | |
| 31 | + | ctx := context.Background() | |
| 32 | + | events := &captureEvents{} | |
| 33 | + | runtime := New(t.TempDir(), events, usecase.NewPrepareDownloadUseCase(nil)) | |
| 34 | + | ||
| 35 | + | output, err := runtime.ResolveDestination(ctx, "artifact", stubResponse{mimeType: "application/pdf"}) | |
| 36 | + | ||
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Equal(t, "artifact.pdf", output.Filename) | |
| 39 | + | runtime.EmitStarted(ctx, output) | |
| 40 | + | runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100) | |
| 41 | + | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | |
| 42 | + | ||
| 43 | + | require.Len(t, events.events, 3) | |
| 44 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 45 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |
| 46 | + | require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001) | |
| 47 | + | require.EqualValues(t, 42, events.events[1].BytesReceived) | |
| 48 | + | require.EqualValues(t, 100, events.events[1].BytesTotal) | |
| 49 | + | require.Equal(t, port.DownloadEventFinished, events.events[2].Type) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | |
| 53 | + | require.NotPanics(t, func() { | |
| 54 | + | _ = New("/tmp/downloads", nil, nil) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRuntimeResolveDestination_NilPreparerReturnsError(t *testing.T) { | |
| 59 | + | runtime := New(t.TempDir(), nil, nil) | |
| 60 | + | ||
| 61 | + | output, err := runtime.ResolveDestination(context.Background(), "artifact", stubResponse{}) | |
| 62 | + | ||
| 63 | + | require.Nil(t, output) | |
| 64 | + | require.Error(t, err) | |
| 65 | + | require.ErrorContains(t, err, "download preparer is required") | |
| 66 | + | } | |
M internal/infrastructure/filtering/interfaces.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 7 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // FilterStore abstracts the WebKit UserContentFilterStore operations. | |
M internal/infrastructure/filtering/manager.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 17 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 17 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | // Compile-time check that Manager satisfies port.FilterManager. | |
M internal/infrastructure/filtering/manager_test.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | |
| 15 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 16 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 16 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 17 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | 18 | "github.com/stretchr/testify/mock" | |
| 19 | 19 | "github.com/stretchr/testify/require" | |
M internal/infrastructure/filtering/mocks/mocks.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 12 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 13 | 13 | mock "github.com/stretchr/testify/mock" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
M internal/infrastructure/filtering/store.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 10 | 9 | "github.com/bnema/puregotk/v4/gio" | |
| 10 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // Store wraps webkit.UserContentFilterStore with Go-friendly async operations. | |
A internal/infrastructure/gtkmenu/renderer.go +240 -0
| @@ -0,0 +1,240 @@ | |||
| 1 | + | package gtkmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/application/port" | |
| 5 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 6 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 7 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type renderItem struct { | |
| 11 | + | item port.MenuItem | |
| 12 | + | separator bool | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | contextMenuBoxClass = "context-menu" | |
| 17 | + | contextMenuPopoverClass = "context-menu-popover" | |
| 18 | + | contextMenuItemClass = "context-menu-item" | |
| 19 | + | contextMenuSeparatorClass = "context-menu-separator" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Renderer shows GTK context menus on the provided anchor widget. | |
| 23 | + | type Renderer struct { | |
| 24 | + | dispatch func(func()) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // NewRenderer creates a new Renderer. | |
| 28 | + | func NewRenderer(dispatch func(func())) *Renderer { | |
| 29 | + | return &Renderer{dispatch: dispatch} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func BuildButtons(items []port.MenuItem) []renderItem { | |
| 33 | + | renderItems := make([]renderItem, 0, len(items)) | |
| 34 | + | for _, item := range items { | |
| 35 | + | if isSeparator(item) { | |
| 36 | + | renderItems = append(renderItems, renderItem{separator: true}) | |
| 37 | + | continue | |
| 38 | + | } | |
| 39 | + | renderItems = append(renderItems, renderItem{item: item}) | |
| 40 | + | } | |
| 41 | + | return renderItems | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func isSeparator(item port.MenuItem) bool { | |
| 45 | + | return item.Action == "" && item.Label == "" | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Show renders a popover menu on the GTK thread. | |
| 49 | + | func (r *Renderer) Show( | |
| 50 | + | items []port.MenuItem, | |
| 51 | + | anchor *gtk.Widget, | |
| 52 | + | x, y int32, | |
| 53 | + | onSelect func(port.MenuItem), | |
| 54 | + | onClose func(), | |
| 55 | + | ) { | |
| 56 | + | if r == nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | run := func() { | |
| 60 | + | showMenu(anchor, BuildButtons(items), x, y, onSelect, onClose) | |
| 61 | + | } | |
| 62 | + | if r.dispatch == nil { | |
| 63 | + | run() | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | r.dispatch(run) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func showMenu( | |
| 70 | + | anchor *gtk.Widget, | |
| 71 | + | items []renderItem, | |
| 72 | + | x, y int32, | |
| 73 | + | onSelect func(port.MenuItem), | |
| 74 | + | onClose func(), | |
| 75 | + | ) { | |
| 76 | + | if anchor == nil { | |
| 77 | + | if onClose != nil { | |
| 78 | + | onClose() | |
| 79 | + | } | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | if !hasRenderableItems(items) { | |
| 83 | + | if onClose != nil { | |
| 84 | + | onClose() | |
| 85 | + | } | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 90 | + | box.AddCssClass(contextMenuBoxClass) | |
| 91 | + | ||
| 92 | + | selected := false | |
| 93 | + | var popover *gtk.Popover | |
| 94 | + | parent := ChoosePopoverParent(anchor, anchor.GetParent()) | |
| 95 | + | popoverHost, overlay := createPopoverHost(parent, x, y) | |
| 96 | + | ||
| 97 | + | for _, item := range items { | |
| 98 | + | if item.separator { | |
| 99 | + | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |
| 100 | + | sep.AddCssClass(contextMenuSeparatorClass) | |
| 101 | + | box.Append(&sep.Widget) | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | btn := gtk.NewButton() | |
| 106 | + | btn.SetLabel(item.item.Label) | |
| 107 | + | btn.AddCssClass("flat") | |
| 108 | + | btn.AddCssClass(contextMenuItemClass) | |
| 109 | + | ||
| 110 | + | menuItem := item.item | |
| 111 | + | clickCb := func(_ gtk.Button) { | |
| 112 | + | selected = true | |
| 113 | + | if onSelect != nil { | |
| 114 | + | onSelect(menuItem) | |
| 115 | + | } | |
| 116 | + | if popover != nil { | |
| 117 | + | popover.Popdown() | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | btn.ConnectClicked(&clickCb) | |
| 121 | + | box.Append(&btn.Widget) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | popover = gtk.NewPopover() | |
| 125 | + | popover.AddCssClass(contextMenuPopoverClass) | |
| 126 | + | popover.SetChild(&box.Widget) | |
| 127 | + | attachPopover(popover, popoverHost, parent) | |
| 128 | + | popover.SetHasArrow(false) | |
| 129 | + | popover.SetAutohide(true) | |
| 130 | + | ||
| 131 | + | if popoverHost == nil { | |
| 132 | + | rect := PopoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) { | |
| 133 | + | if anchor == nil || parent == nil || anchor == parent { | |
| 134 | + | return srcX, srcY, false | |
| 135 | + | } | |
| 136 | + | var destX, destY float64 | |
| 137 | + | if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) { | |
| 138 | + | return srcX, srcY, false | |
| 139 | + | } | |
| 140 | + | return destX, destY, true | |
| 141 | + | }) | |
| 142 | + | popover.SetPointingTo(rect) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | closedCb := func(_ gtk.Popover) { | |
| 146 | + | if !selected && onClose != nil { | |
| 147 | + | onClose() | |
| 148 | + | } | |
| 149 | + | cleanupPopover(popover, popoverHost, overlay) | |
| 150 | + | } | |
| 151 | + | popover.ConnectClosed(&closedCb) | |
| 152 | + | ||
| 153 | + | popupPopover(popover, popoverHost) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // ChoosePopoverParent picks the best parent for a GTK popover. | |
| 157 | + | func ChoosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget { | |
| 158 | + | if parent != nil { | |
| 159 | + | return parent | |
| 160 | + | } | |
| 161 | + | return anchor | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // PopoverPointingRect builds the popover pointing rectangle. | |
| 165 | + | func PopoverPointingRect( | |
| 166 | + | x, y int32, | |
| 167 | + | translate func(srcX, srcY float64) (float64, float64, bool), | |
| 168 | + | ) *gdk.Rectangle { | |
| 169 | + | destX, destY := float64(x), float64(y) | |
| 170 | + | if translate != nil { | |
| 171 | + | if tx, ty, ok := translate(destX, destY); ok { | |
| 172 | + | destX, destY = tx, ty | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1} | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) { | |
| 179 | + | overlay := overlayFromWidget(parent) | |
| 180 | + | if overlay == nil { | |
| 181 | + | return nil, nil | |
| 182 | + | } | |
| 183 | + | host := gtk.NewMenuButton() | |
| 184 | + | host.SetCanFocus(false) | |
| 185 | + | host.SetHalign(gtk.AlignStartValue) | |
| 186 | + | host.SetValign(gtk.AlignStartValue) | |
| 187 | + | host.SetMarginStart(int(x)) | |
| 188 | + | host.SetMarginTop(int(y)) | |
| 189 | + | host.SetOpacity(0) | |
| 190 | + | host.SetSizeRequest(1, 1) | |
| 191 | + | host.SetVisible(true) | |
| 192 | + | overlay.AddOverlay(&host.Widget) | |
| 193 | + | overlay.SetClipOverlay(&host.Widget, false) | |
| 194 | + | overlay.SetMeasureOverlay(&host.Widget, false) | |
| 195 | + | return host, overlay | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay { | |
| 199 | + | if widget == nil || widget.GoPointer() == 0 { | |
| 200 | + | return nil | |
| 201 | + | } | |
| 202 | + | if !gobject.TypeCheckInstanceIsAPtr(widget.GoPointer(), gtk.OverlayGLibType()) { | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | return gtk.OverlayNewFromInternalPtr(widget.GoPointer()) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) { | |
| 209 | + | if host != nil { | |
| 210 | + | host.SetPopover(popover) | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | popover.SetParent(parent) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) { | |
| 217 | + | if host != nil && overlay != nil { | |
| 218 | + | host.SetPopover(nil) | |
| 219 | + | overlay.RemoveOverlay(&host.Widget) | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | popover.Unparent() | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) { | |
| 226 | + | if host != nil { | |
| 227 | + | host.Popup() | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | popover.Popup() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func hasRenderableItems(items []renderItem) bool { | |
| 234 | + | for _, item := range items { | |
| 235 | + | if !item.separator { | |
| 236 | + | return true | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | return false | |
| 240 | + | } | |
A internal/infrastructure/gtkmenu/renderer_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package gtkmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestChoosePopoverParent(t *testing.T) { | |
| 12 | + | anchor := >k.Widget{} | |
| 13 | + | parent := >k.Widget{} | |
| 14 | + | ||
| 15 | + | require.Same(t, parent, ChoosePopoverParent(anchor, parent)) | |
| 16 | + | require.Same(t, anchor, ChoosePopoverParent(anchor, nil)) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestPopoverPointingRect(t *testing.T) { | |
| 20 | + | t.Run("uses translated parent coordinates when available", func(t *testing.T) { | |
| 21 | + | rect := PopoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) { | |
| 22 | + | require.InDelta(t, 64.0, srcX, 0.0001) | |
| 23 | + | require.InDelta(t, 96.0, srcY, 0.0001) | |
| 24 | + | return 24, 48, true | |
| 25 | + | }) | |
| 26 | + | ||
| 27 | + | require.Equal(t, 24, rect.X) | |
| 28 | + | require.Equal(t, 48, rect.Y) | |
| 29 | + | require.Equal(t, 1, rect.Width) | |
| 30 | + | require.Equal(t, 1, rect.Height) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) { | |
| 34 | + | rect := PopoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) { | |
| 35 | + | return srcX, srcY, false | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | require.Equal(t, 10, rect.X) | |
| 39 | + | require.Equal(t, 20, rect.Y) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestBuildButtons(t *testing.T) { | |
| 44 | + | items := []port.MenuItem{ | |
| 45 | + | {Action: port.MenuActionBack, Label: "Back"}, | |
| 46 | + | {}, | |
| 47 | + | {Action: port.MenuActionReload, Label: "Reload"}, | |
| 48 | + | {Action: port.MenuActionInspectElement, Label: "Inspect Element"}, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | renderItems := BuildButtons(items) | |
| 52 | + | require.Len(t, renderItems, 4) | |
| 53 | + | ||
| 54 | + | require.Equal(t, "Back", renderItems[0].item.Label) | |
| 55 | + | require.False(t, renderItems[0].separator) | |
| 56 | + | ||
| 57 | + | require.True(t, renderItems[1].separator) | |
| 58 | + | ||
| 59 | + | require.Equal(t, "Reload", renderItems[2].item.Label) | |
| 60 | + | require.False(t, renderItems[2].separator) | |
| 61 | + | ||
| 62 | + | require.Equal(t, "Inspect Element", renderItems[3].item.Label) | |
| 63 | + | require.False(t, renderItems[3].separator) | |
| 64 | + | } | |
M internal/infrastructure/handlers/clipboard.go +46 -28
| @@ -4,23 +4,20 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // ClipboardHandler handles clipboard-related messages from webviews. | |
| 12 | 13 | type ClipboardHandler struct { | |
| 13 | - | clipboard port.Clipboard | |
| 14 | - | autoCopyConfig port.AutoCopyConfig | |
| 15 | - | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |
| 14 | + | orchestrator port.ClipboardTextOrchestrator | |
| 16 | 15 | } | |
| 17 | 16 | ||
| 18 | 17 | // NewClipboardHandler creates a new ClipboardHandler. | |
| 19 | - | func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler { | |
| 18 | + | func NewClipboardHandler(orchestrator port.ClipboardTextOrchestrator) *ClipboardHandler { | |
| 20 | 19 | return &ClipboardHandler{ | |
| 21 | - | clipboard: clipboard, | |
| 22 | - | autoCopyConfig: autoCopyConfig, | |
| 23 | - | onCopied: onCopied, | |
| 20 | + | orchestrator: orchestrator, | |
| 24 | 21 | } | |
| 25 | 22 | } | |
| 26 | 23 | ||
| @@ -29,38 +26,58 @@ type autoCopyRequest struct { | |||
| 29 | 26 | Text string `json:"text"` | |
| 30 | 27 | } | |
| 31 | 28 | ||
| 29 | + | type explicitCopyRequest struct { | |
| 30 | + | Text string `json:"text"` | |
| 31 | + | Action string `json:"action"` | |
| 32 | + | } | |
| 33 | + | ||
| 32 | 34 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |
| 33 | 35 | // It copies the selected text to the clipboard if the feature is enabled. | |
| 34 | 36 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |
| 35 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 37 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 36 | 38 | log := logging.FromContext(ctx) | |
| 37 | 39 | ||
| 38 | - | // Check if feature is enabled | |
| 39 | - | if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() { | |
| 40 | - | return nil, nil | |
| 41 | - | } | |
| 42 | - | ||
| 43 | 40 | var req autoCopyRequest | |
| 44 | 41 | if err := json.Unmarshal(payload, &req); err != nil { | |
| 45 | 42 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | |
| 46 | 43 | return nil, nil // Silently ignore malformed requests | |
| 47 | 44 | } | |
| 48 | - | ||
| 49 | - | // Skip empty or very short selections (< 2 chars) | |
| 50 | - | if len(req.Text) < 2 { | |
| 45 | + | if h.orchestrator == nil { | |
| 51 | 46 | return nil, nil | |
| 52 | 47 | } | |
| 53 | - | ||
| 54 | - | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | |
| 55 | - | log.Debug().Err(err).Msg("failed to write selection to clipboard") | |
| 56 | - | return nil, nil // Don't propagate error to JS | |
| 48 | + | if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{ | |
| 49 | + | Text: req.Text, | |
| 50 | + | SourceEngine: dto.ClipboardSourceWebKit, | |
| 51 | + | ViewID: uint64(webviewID), | |
| 52 | + | }); err != nil { | |
| 53 | + | log.Debug().Err(err).Msg("clipboard selection handling failed") | |
| 57 | 54 | } | |
| 58 | 55 | ||
| 59 | - | log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard") | |
| 56 | + | return nil, nil | |
| 57 | + | }) | |
| 58 | + | } | |
| 60 | 59 | ||
| 61 | - | // Notify UI for toast feedback (if callback is set) | |
| 62 | - | if h.onCopied != nil { | |
| 63 | - | h.onCopied(len(req.Text)) | |
| 60 | + | // HandleExplicitCopy handles the explicit_text_copy message from JS. | |
| 61 | + | func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { | |
| 62 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 63 | + | log := logging.FromContext(ctx) | |
| 64 | + | ||
| 65 | + | var req explicitCopyRequest | |
| 66 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 67 | + | log.Debug().Err(err).Msg("failed to unmarshal explicit copy payload") | |
| 68 | + | return nil, nil | |
| 69 | + | } | |
| 70 | + | if h.orchestrator == nil { | |
| 71 | + | return nil, nil | |
| 72 | + | } | |
| 73 | + | if err := h.orchestrator.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{ | |
| 74 | + | Text: req.Text, | |
| 75 | + | Action: req.Action, | |
| 76 | + | SourceEngine: dto.ClipboardSourceWebKit, | |
| 77 | + | ViewID: uint64(webviewID), | |
| 78 | + | NativeHandled: true, | |
| 79 | + | }); err != nil { | |
| 80 | + | log.Debug().Err(err).Msg("clipboard explicit handling failed") | |
| 64 | 81 | } | |
| 65 | 82 | ||
| 66 | 83 | return nil, nil | |
| @@ -71,16 +88,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |||
| 71 | 88 | func RegisterClipboardHandlers( | |
| 72 | 89 | ctx context.Context, | |
| 73 | 90 | router port.WebUIHandlerRouter, | |
| 74 | - | clipboard port.Clipboard, | |
| 75 | - | autoCopyConfig port.AutoCopyConfig, | |
| 76 | - | onCopied func(textLen int), | |
| 91 | + | orchestrator port.ClipboardTextOrchestrator, | |
| 77 | 92 | ) error { | |
| 78 | - | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | |
| 93 | + | handler := NewClipboardHandler(orchestrator) | |
| 79 | 94 | ||
| 80 | 95 | // Register auto_copy_selection handler | |
| 81 | 96 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | |
| 82 | 97 | return err | |
| 83 | 98 | } | |
| 99 | + | if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 84 | 102 | ||
| 85 | 103 | log := logging.FromContext(ctx) | |
| 86 | 104 | log.Info().Msg("registered clipboard handlers") | |
A internal/infrastructure/handlers/clipboard_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type recordingClipboardOrchestrator struct { | |
| 14 | + | mu sync.Mutex | |
| 15 | + | selection dto.SelectionClipboardInput | |
| 16 | + | explicit dto.ExplicitClipboardInput | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | |
| 20 | + | r.mu.Lock() | |
| 21 | + | defer r.mu.Unlock() | |
| 22 | + | r.selection = input | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error { | |
| 27 | + | r.mu.Lock() | |
| 28 | + | defer r.mu.Unlock() | |
| 29 | + | r.explicit = input | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *testing.T) { | |
| 34 | + | ctx := context.Background() | |
| 35 | + | orchestrator := &recordingClipboardOrchestrator{} | |
| 36 | + | handler := NewClipboardHandler(orchestrator) | |
| 37 | + | ||
| 38 | + | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | ||
| 41 | + | orchestrator.mu.Lock() | |
| 42 | + | defer orchestrator.mu.Unlock() | |
| 43 | + | require.Equal(t, "selected text", orchestrator.selection.Text) | |
| 44 | + | require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.selection.SourceEngine) | |
| 45 | + | require.Equal(t, uint64(42), orchestrator.selection.ViewID) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | |
| 49 | + | ctx := context.Background() | |
| 50 | + | orchestrator := &recordingClipboardOrchestrator{} | |
| 51 | + | handler := NewClipboardHandler(orchestrator) | |
| 52 | + | ||
| 53 | + | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | ||
| 56 | + | orchestrator.mu.Lock() | |
| 57 | + | defer orchestrator.mu.Unlock() | |
| 58 | + | require.Equal(t, "copied text", orchestrator.explicit.Text) | |
| 59 | + | require.Equal(t, "copy", orchestrator.explicit.Action) | |
| 60 | + | require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine) | |
| 61 | + | require.Equal(t, uint64(42), orchestrator.explicit.ViewID) | |
| 62 | + | require.True(t, orchestrator.explicit.NativeHandled) | |
| 63 | + | } | |
M internal/infrastructure/handlers/register.go +2 -2
| @@ -52,8 +52,8 @@ func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port. | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | // Clipboard handlers (for auto-copy on selection feature) | |
| 55 | - | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | |
| 56 | - | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | |
| 55 | + | if deps.ClipboardTextOrchestrator != nil { | |
| 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil { | |
| 57 | 57 | return err | |
| 58 | 58 | } | |
| 59 | 59 | } | |
A internal/infrastructure/runtimeprofile/profile.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | browserLaunchSocketName = "browser-launch.sock" | |
| 11 | + | engineWebKit = "webkit" | |
| 12 | + | engineCEF = "cef" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Mode identifies the execution sandbox mode. | |
| 16 | + | type Mode string | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | ModeProd Mode = "prod" | |
| 20 | + | ModeDev Mode = "dev" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // BasePaths holds the already-resolved dumber XDG homes used in prod. | |
| 24 | + | type BasePaths struct { | |
| 25 | + | ConfigHome string | |
| 26 | + | DataHome string | |
| 27 | + | StateHome string | |
| 28 | + | CacheHome string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ResolveInput contains the external inputs needed to resolve a runtime profile. | |
| 32 | + | type ResolveInput struct { | |
| 33 | + | Env func(string) string | |
| 34 | + | CWD func() (string, error) | |
| 35 | + | Engine string | |
| 36 | + | Base BasePaths | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // SharedPaths are shared by the whole runtime sandbox. | |
| 40 | + | type SharedPaths struct { | |
| 41 | + | // RootDir is only populated when the shared sandbox has a single root | |
| 42 | + | // directory (for example ENV=dev under .dev/dumber). It is intentionally | |
| 43 | + | // empty in prod, where shared paths come from distinct XDG homes. | |
| 44 | + | RootDir string | |
| 45 | + | ConfigDir string | |
| 46 | + | DataDir string | |
| 47 | + | StateDir string | |
| 48 | + | CacheDir string | |
| 49 | + | LogDir string | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // EnginePaths are engine-specific technical paths. | |
| 53 | + | type EnginePaths struct { | |
| 54 | + | Name string | |
| 55 | + | RootDir string | |
| 56 | + | RuntimeDir string | |
| 57 | + | LogDir string | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // IPCPaths are runtime coordination paths. | |
| 61 | + | type IPCPaths struct { | |
| 62 | + | RuntimeDir string | |
| 63 | + | BrowserLaunchSocket string | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Profile is the fully resolved runtime profile for one mode+engine namespace. | |
| 67 | + | type Profile struct { | |
| 68 | + | Mode Mode | |
| 69 | + | Engine string | |
| 70 | + | Shared SharedPaths | |
| 71 | + | EnginePaths EnginePaths | |
| 72 | + | IPC IPCPaths | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Resolve builds a runtime profile from pure inputs. | |
| 76 | + | func Resolve(input ResolveInput) (Profile, error) { | |
| 77 | + | engine := normalizeEngine(input.Engine) | |
| 78 | + | if envValue(input.Env, "ENV") == string(ModeDev) { | |
| 79 | + | cwd, err := cwdValue(input.CWD) | |
| 80 | + | if err != nil { | |
| 81 | + | return Profile{}, fmt.Errorf("resolve cwd: %w", err) | |
| 82 | + | } | |
| 83 | + | root := filepath.Join(cwd, ".dev", "dumber") | |
| 84 | + | engineRoot := filepath.Join(root, "engines", engine) | |
| 85 | + | ipcRoot := filepath.Join(root, "runtime", engine) | |
| 86 | + | return Profile{ | |
| 87 | + | Mode: ModeDev, | |
| 88 | + | Engine: engine, | |
| 89 | + | Shared: SharedPaths{ | |
| 90 | + | RootDir: root, | |
| 91 | + | ConfigDir: filepath.Join(root, "config"), | |
| 92 | + | DataDir: filepath.Join(root, "data"), | |
| 93 | + | StateDir: filepath.Join(root, "state"), | |
| 94 | + | CacheDir: filepath.Join(root, "cache"), | |
| 95 | + | LogDir: filepath.Join(root, "logs"), | |
| 96 | + | }, | |
| 97 | + | EnginePaths: EnginePaths{ | |
| 98 | + | Name: engine, | |
| 99 | + | RootDir: engineRoot, | |
| 100 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |
| 101 | + | LogDir: filepath.Join(engineRoot, "logs"), | |
| 102 | + | }, | |
| 103 | + | IPC: IPCPaths{ | |
| 104 | + | RuntimeDir: ipcRoot, | |
| 105 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |
| 106 | + | }, | |
| 107 | + | }, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | shared := SharedPaths{ | |
| 111 | + | ConfigDir: input.Base.ConfigHome, | |
| 112 | + | DataDir: input.Base.DataHome, | |
| 113 | + | StateDir: input.Base.StateHome, | |
| 114 | + | CacheDir: input.Base.CacheHome, | |
| 115 | + | LogDir: filepath.Join(input.Base.StateHome, "logs"), | |
| 116 | + | } | |
| 117 | + | engineRoot := filepath.Join(shared.StateDir, "engines", engine) | |
| 118 | + | ipcRoot := filepath.Join(shared.StateDir, "runtime", engine) | |
| 119 | + | return Profile{ | |
| 120 | + | Mode: ModeProd, | |
| 121 | + | Engine: engine, | |
| 122 | + | Shared: shared, | |
| 123 | + | EnginePaths: EnginePaths{ | |
| 124 | + | Name: engine, | |
| 125 | + | RootDir: engineRoot, | |
| 126 | + | RuntimeDir: filepath.Join(engineRoot, "runtime"), | |
| 127 | + | LogDir: filepath.Join(engineRoot, "logs"), | |
| 128 | + | }, | |
| 129 | + | IPC: IPCPaths{ | |
| 130 | + | RuntimeDir: ipcRoot, | |
| 131 | + | BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName), | |
| 132 | + | }, | |
| 133 | + | }, nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // normalizeEngine trims and lowercases the runtime engine name. | |
| 137 | + | // Supported engines are "webkit" and "cef"; any other value falls back to | |
| 138 | + | // the default "webkit" runtime namespace. | |
| 139 | + | func normalizeEngine(engine string) string { | |
| 140 | + | switch normalized := strings.ToLower(strings.TrimSpace(engine)); normalized { | |
| 141 | + | case "", engineWebKit: | |
| 142 | + | return engineWebKit | |
| 143 | + | case engineCEF: | |
| 144 | + | return engineCEF | |
| 145 | + | default: | |
| 146 | + | return engineWebKit | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func envValue(get func(string) string, key string) string { | |
| 151 | + | if get == nil { | |
| 152 | + | return "" | |
| 153 | + | } | |
| 154 | + | return get(key) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func cwdValue(get func() (string, error)) (string, error) { | |
| 158 | + | if get == nil { | |
| 159 | + | return "", fmt.Errorf("cwd getter is nil") | |
| 160 | + | } | |
| 161 | + | return get() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | |
| 165 | + | func (p Profile) CEFUserDataDir() string { | |
| 166 | + | if p.Mode == ModeDev { | |
| 167 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |
| 168 | + | } | |
| 169 | + | return filepath.Join(p.Shared.DataDir, "cef_user_data") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // CEFLogFile returns the resolved default CEF runtime log path. | |
| 173 | + | func (p Profile) CEFLogFile() string { | |
| 174 | + | return filepath.Join(p.EnginePaths.LogDir, "cef_runtime.log") | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // WebKitDataDir returns the resolved WebKit website data directory. | |
| 178 | + | func (p Profile) WebKitDataDir() string { | |
| 179 | + | if p.Mode == ModeDev { | |
| 180 | + | return filepath.Join(p.EnginePaths.RootDir, "data") | |
| 181 | + | } | |
| 182 | + | return p.Shared.DataDir | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // WebKitCacheDir returns the resolved WebKit cache directory. | |
| 186 | + | func (p Profile) WebKitCacheDir() string { | |
| 187 | + | if p.Mode == ModeDev { | |
| 188 | + | return filepath.Join(p.EnginePaths.RootDir, "cache") | |
| 189 | + | } | |
| 190 | + | // Keep the current prod layout under state for non-user-managed technical | |
| 191 | + | // engine cache data; this intentionally preserves existing WebKit behavior. | |
| 192 | + | return filepath.Join(p.Shared.StateDir, "webkit-cache") | |
| 193 | + | } | |
A internal/infrastructure/runtimeprofile/profile_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolve_ProdPreservesSharedLayout(t *testing.T) { | |
| 11 | + | profile, err := Resolve(ResolveInput{ | |
| 12 | + | Env: func(string) string { return "" }, | |
| 13 | + | Engine: "webkit", | |
| 14 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 15 | + | Base: BasePaths{ | |
| 16 | + | ConfigHome: "/home/u/.config/dumber", | |
| 17 | + | DataHome: "/home/u/.local/share/dumber", | |
| 18 | + | StateHome: "/home/u/.local/state/dumber", | |
| 19 | + | CacheHome: "/home/u/.cache/dumber", | |
| 20 | + | }, | |
| 21 | + | }) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Equal(t, ModeProd, profile.Mode) | |
| 24 | + | require.Equal(t, "/home/u/.config/dumber", profile.Shared.ConfigDir) | |
| 25 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.Shared.DataDir) | |
| 26 | + | require.Equal(t, "/home/u/.local/state/dumber", profile.Shared.StateDir) | |
| 27 | + | require.Equal(t, "/home/u/.cache/dumber", profile.Shared.CacheDir) | |
| 28 | + | require.Equal(t, "/home/u/.local/state/dumber/logs", profile.Shared.LogDir) | |
| 29 | + | require.Equal(t, "/home/u/.local/share/dumber/cef_user_data", profile.CEFUserDataDir()) | |
| 30 | + | require.Equal(t, "/home/u/.local/share/dumber", profile.WebKitDataDir()) | |
| 31 | + | require.Equal(t, "/home/u/.local/state/dumber/webkit-cache", profile.WebKitCacheDir()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) { | |
| 35 | + | profile, err := Resolve(ResolveInput{ | |
| 36 | + | Env: func(key string) string { | |
| 37 | + | if key == "ENV" { | |
| 38 | + | return "dev" | |
| 39 | + | } | |
| 40 | + | return "" | |
| 41 | + | }, | |
| 42 | + | Engine: "cef", | |
| 43 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 44 | + | }) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.Equal(t, ModeDev, profile.Mode) | |
| 47 | + | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | |
| 48 | + | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | |
| 49 | + | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | |
| 50 | + | require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | |
| 54 | + | devEnv := func(key string) string { | |
| 55 | + | if key == "ENV" { | |
| 56 | + | return "dev" | |
| 57 | + | } | |
| 58 | + | return "" | |
| 59 | + | } | |
| 60 | + | repoCWD := func() (string, error) { return "/repo", nil } | |
| 61 | + | ||
| 62 | + | cefProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "cef", CWD: repoCWD}) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | wkProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "webkit", CWD: repoCWD}) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | require.NotEqual(t, cefProfile.EnginePaths.RootDir, wkProfile.EnginePaths.RootDir) | |
| 68 | + | require.NotEqual(t, cefProfile.IPC.RuntimeDir, wkProfile.IPC.RuntimeDir) | |
| 69 | + | require.NotEqual(t, cefProfile.IPC.BrowserLaunchSocket, wkProfile.IPC.BrowserLaunchSocket) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestResolve_DefaultsEmptyEngineToWebKit(t *testing.T) { | |
| 73 | + | profile, err := Resolve(ResolveInput{ | |
| 74 | + | Env: func(string) string { return "" }, | |
| 75 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 76 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 77 | + | }) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Equal(t, "webkit", profile.Engine) | |
| 80 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestResolve_NormalizesEngineWhitespaceAndCase(t *testing.T) { | |
| 84 | + | profile, err := Resolve(ResolveInput{ | |
| 85 | + | Env: func(string) string { return "" }, | |
| 86 | + | Engine: " CEF ", | |
| 87 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 88 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 89 | + | }) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | require.Equal(t, "cef", profile.Engine) | |
| 92 | + | require.Equal(t, "/state/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestResolve_DefaultsUnknownEngineToWebKit(t *testing.T) { | |
| 96 | + | profile, err := Resolve(ResolveInput{ | |
| 97 | + | Env: func(string) string { return "" }, | |
| 98 | + | Engine: "wekbit", | |
| 99 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 100 | + | Base: BasePaths{StateHome: "/state", DataHome: "/data"}, | |
| 101 | + | }) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | require.Equal(t, "webkit", profile.Engine) | |
| 104 | + | require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestResolve_DevFailsWhenCWDUnavailable(t *testing.T) { | |
| 108 | + | want := errors.New("boom") | |
| 109 | + | _, err := Resolve(ResolveInput{ | |
| 110 | + | Env: func(key string) string { | |
| 111 | + | if key == "ENV" { | |
| 112 | + | return "dev" | |
| 113 | + | } | |
| 114 | + | return "" | |
| 115 | + | }, | |
| 116 | + | Engine: "cef", | |
| 117 | + | CWD: func() (string, error) { return "", want }, | |
| 118 | + | }) | |
| 119 | + | require.ErrorIs(t, err, want) | |
| 120 | + | } | |
M internal/infrastructure/textinput/webview_target.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor. | |
M internal/infrastructure/transcoder/pipeline.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "net" | |
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | - | "github.com/ebitengine/purego" | |
| 16 | + | "github.com/bnema/purego" | |
| 17 | 17 | "github.com/rs/zerolog" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | |
M internal/infrastructure/webkit/context.go +2 -2
| @@ -7,12 +7,12 @@ import ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 10 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 11 | 11 | "github.com/rs/zerolog" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // FaviconDatabase is a type alias for webkit.FaviconDatabase. | |
| 15 | - | // Re-exported for use by UI layer without direct puregotk-webkit import. | |
| 15 | + | // Re-exported for use by the UI layer without a direct WebKit binding import. | |
| 16 | 16 | type FaviconDatabase = webkit.FaviconDatabase | |
| 17 | 17 | ||
| 18 | 18 | // WebKitContext manages the shared WebContext and persistent NetworkSession. | |
A internal/infrastructure/webkit/context_menu.go +631 -0
| @@ -0,0 +1,631 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "mime" | |
| 11 | + | "net" | |
| 12 | + | "net/http" | |
| 13 | + | "net/url" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | "unicode/utf8" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/dumber/internal/application/port" | |
| 19 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 20 | + | "github.com/bnema/dumber/internal/logging" | |
| 21 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 22 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | |
| 26 | + | // over HTTP. This is the WebKit-specific seam for the shared context menu | |
| 27 | + | // pipeline's copy-image and save-image actions. | |
| 28 | + | type contextMenuResolver struct { | |
| 29 | + | client *http.Client | |
| 30 | + | allowPrivateHosts bool | |
| 31 | + | lookupIPAddr func(context.Context, string) ([]net.IPAddr, error) | |
| 32 | + | dialContext func(context.Context, string, string) (net.Conn, error) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |
| 36 | + | ||
| 37 | + | const maxImageFetchBytes = 50 * 1024 * 1024 | |
| 38 | + | ||
| 39 | + | // ResolveImageData fetches raw image bytes from the given URI. | |
| 40 | + | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) { | |
| 41 | + | if imageURI == "" { | |
| 42 | + | return entity.ImageData{}, fmt.Errorf("empty image URI") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | parsed, parseErr := url.Parse(imageURI) | |
| 46 | + | if parseErr != nil { | |
| 47 | + | return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) | |
| 48 | + | } | |
| 49 | + | switch strings.ToLower(parsed.Scheme) { | |
| 50 | + | case "data": | |
| 51 | + | return resolveDataURIImageData(imageURI) | |
| 52 | + | case "http", "https": | |
| 53 | + | default: | |
| 54 | + | return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme) | |
| 55 | + | } | |
| 56 | + | if err := r.validateImageURL(ctx, parsed); err != nil { | |
| 57 | + | return entity.ImageData{}, err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | client := r.imageFetchClient(ctx) | |
| 61 | + | ||
| 62 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody) | |
| 63 | + | if err != nil { | |
| 64 | + | return entity.ImageData{}, fmt.Errorf("build request: %w", err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | resp, err := client.Do(req) | |
| 68 | + | if err != nil { | |
| 69 | + | return entity.ImageData{}, fmt.Errorf("fetch image: %w", err) | |
| 70 | + | } | |
| 71 | + | defer func() { _ = resp.Body.Close() }() | |
| 72 | + | ||
| 73 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 74 | + | return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | var buf bytes.Buffer | |
| 78 | + | n, err := io.CopyN(&buf, resp.Body, maxImageFetchBytes+1) | |
| 79 | + | if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { | |
| 80 | + | return entity.ImageData{}, fmt.Errorf("read image body: %w", err) | |
| 81 | + | } | |
| 82 | + | if n == 0 { | |
| 83 | + | return entity.ImageData{}, fmt.Errorf("read image body: empty image data") | |
| 84 | + | } | |
| 85 | + | if n > maxImageFetchBytes { | |
| 86 | + | return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) | |
| 87 | + | } | |
| 88 | + | data := buf.Bytes() | |
| 89 | + | responseURL := parsed.String() | |
| 90 | + | if resp.Request != nil && resp.Request.URL != nil { | |
| 91 | + | responseURL = resp.Request.URL.String() | |
| 92 | + | } | |
| 93 | + | return resolvedImageData(data, resp.Header.Get("Content-Type"), responseURL) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func resolveDataURIImageData(imageURI string) (entity.ImageData, error) { | |
| 97 | + | payload, ok := strings.CutPrefix(imageURI, "data:") | |
| 98 | + | if !ok { | |
| 99 | + | return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", "") | |
| 100 | + | } | |
| 101 | + | meta, dataPart, found := strings.Cut(payload, ",") | |
| 102 | + | if !found { | |
| 103 | + | return entity.ImageData{}, fmt.Errorf("parse image URI: invalid data URI") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | isBase64 := false | |
| 107 | + | mimeType := "" | |
| 108 | + | for _, part := range strings.Split(meta, ";") { | |
| 109 | + | part = strings.TrimSpace(part) | |
| 110 | + | if part == "" { | |
| 111 | + | continue | |
| 112 | + | } | |
| 113 | + | if strings.EqualFold(part, "base64") { | |
| 114 | + | isBase64 = true | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | if mimeType == "" { | |
| 118 | + | mimeType = part | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | var data []byte | |
| 123 | + | var err error | |
| 124 | + | if isBase64 { | |
| 125 | + | data, err = base64.StdEncoding.DecodeString(dataPart) | |
| 126 | + | } else { | |
| 127 | + | var decoded string | |
| 128 | + | decoded, err = url.PathUnescape(dataPart) | |
| 129 | + | data = []byte(decoded) | |
| 130 | + | } | |
| 131 | + | if err != nil { | |
| 132 | + | return entity.ImageData{}, fmt.Errorf("read image body: decode data URI: %w", err) | |
| 133 | + | } | |
| 134 | + | return resolvedImageData(data, mimeType, imageURI) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func resolvedImageData(data []byte, headerType, source string) (entity.ImageData, error) { | |
| 138 | + | if len(data) == 0 { | |
| 139 | + | return entity.ImageData{}, fmt.Errorf("read image body: empty image data") | |
| 140 | + | } | |
| 141 | + | if len(data) > maxImageFetchBytes { | |
| 142 | + | return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes) | |
| 143 | + | } | |
| 144 | + | rawDetectedType := strings.ToLower(http.DetectContentType(data)) | |
| 145 | + | detectedType := validatedImageMimeType(rawDetectedType) | |
| 146 | + | mimeType := detectedType | |
| 147 | + | if mimeType == "" && rawDetectedType == "application/octet-stream" { | |
| 148 | + | mimeType = validatedImageMimeType(headerType) | |
| 149 | + | } | |
| 150 | + | if mimeType == "" { | |
| 151 | + | return entity.ImageData{}, fmt.Errorf( | |
| 152 | + | "read image body: content is not an image (detected=%q, header=%q, url=%q)", | |
| 153 | + | rawDetectedType, | |
| 154 | + | headerType, | |
| 155 | + | source, | |
| 156 | + | ) | |
| 157 | + | } | |
| 158 | + | return entity.ImageData{Bytes: data, MimeType: mimeType}, nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client { | |
| 162 | + | base := r.client | |
| 163 | + | if base == nil { | |
| 164 | + | base = defaultImageFetchClient | |
| 165 | + | } | |
| 166 | + | client := *base | |
| 167 | + | transport := client.Transport | |
| 168 | + | if transport == nil { | |
| 169 | + | transport = http.DefaultTransport | |
| 170 | + | } | |
| 171 | + | if stdTransport, ok := transport.(*http.Transport); ok { | |
| 172 | + | cloned := stdTransport.Clone() | |
| 173 | + | cloned.DialContext = r.dialImageHostContext | |
| 174 | + | client.Transport = cloned | |
| 175 | + | } else { | |
| 176 | + | client.Transport = &validatedImageRoundTripper{ | |
| 177 | + | base: transport, | |
| 178 | + | validator: r.validateImageURL, | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | client.CheckRedirect = func(req *http.Request, _ []*http.Request) error { | |
| 182 | + | return r.validateImageURL(req.Context(), req.URL) | |
| 183 | + | } | |
| 184 | + | return &client | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (r *contextMenuResolver) validateImageURL(ctx context.Context, imageURL *url.URL) error { | |
| 188 | + | if imageURL == nil { | |
| 189 | + | return fmt.Errorf("fetch image: missing URL") | |
| 190 | + | } | |
| 191 | + | switch strings.ToLower(imageURL.Scheme) { | |
| 192 | + | case "http", "https": | |
| 193 | + | default: | |
| 194 | + | return fmt.Errorf("fetch image: unsupported URI scheme %q", imageURL.Scheme) | |
| 195 | + | } | |
| 196 | + | if imageURL.Host == "" { | |
| 197 | + | return fmt.Errorf("fetch image: missing host") | |
| 198 | + | } | |
| 199 | + | hostname := imageURL.Hostname() | |
| 200 | + | if err := r.validateImageHost(ctx, hostname); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (r *contextMenuResolver) validateImageHost(ctx context.Context, host string) error { | |
| 207 | + | if host == "" { | |
| 208 | + | return fmt.Errorf("fetch image: missing host") | |
| 209 | + | } | |
| 210 | + | if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") { | |
| 211 | + | if r.allowPrivateHosts { | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | return fmt.Errorf("fetch image: private host %q not allowed", host) | |
| 215 | + | } | |
| 216 | + | ip := net.ParseIP(host) | |
| 217 | + | if ip == nil { | |
| 218 | + | addrs, err := r.allowedImageHostAddrs(ctx, host) | |
| 219 | + | if err != nil { | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | if len(addrs) == 0 { | |
| 223 | + | return fmt.Errorf("fetch image: private host %q not allowed", host) | |
| 224 | + | } | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | if isPrivateImageIP(ip) && !r.allowPrivateHosts { | |
| 228 | + | return fmt.Errorf("fetch image: private host %q not allowed", host) | |
| 229 | + | } | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (r *contextMenuResolver) allowedImageHostAddrs(ctx context.Context, host string) ([]net.IPAddr, error) { | |
| 234 | + | lookup := r.lookupIPAddr | |
| 235 | + | if lookup == nil { | |
| 236 | + | lookup = net.DefaultResolver.LookupIPAddr | |
| 237 | + | } | |
| 238 | + | addrs, err := lookup(ctx, host) | |
| 239 | + | if err != nil { | |
| 240 | + | return nil, fmt.Errorf("fetch image: resolve host %q: %w", host, err) | |
| 241 | + | } | |
| 242 | + | if r.allowPrivateHosts { | |
| 243 | + | return addrs, nil | |
| 244 | + | } | |
| 245 | + | allowed := make([]net.IPAddr, 0, len(addrs)) | |
| 246 | + | for _, addr := range addrs { | |
| 247 | + | if !isPrivateImageIP(addr.IP) { | |
| 248 | + | allowed = append(allowed, addr) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | return allowed, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (r *contextMenuResolver) dialImageHostContext(ctx context.Context, network, addr string) (net.Conn, error) { | |
| 255 | + | host, imagePort, err := net.SplitHostPort(addr) | |
| 256 | + | if err != nil { | |
| 257 | + | return nil, fmt.Errorf("fetch image: parse dial address %q: %w", addr, err) | |
| 258 | + | } | |
| 259 | + | if host == "" { | |
| 260 | + | return nil, fmt.Errorf("fetch image: missing host") | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | dial := r.dialContext | |
| 264 | + | if dial == nil { | |
| 265 | + | dial = (&net.Dialer{}).DialContext | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | if ip := net.ParseIP(host); ip != nil { | |
| 269 | + | if isPrivateImageIP(ip) && !r.allowPrivateHosts { | |
| 270 | + | return nil, fmt.Errorf("fetch image: private host %q not allowed", host) | |
| 271 | + | } | |
| 272 | + | return dial(ctx, network, net.JoinHostPort(ip.String(), imagePort)) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | addrs, err := r.allowedImageHostAddrs(ctx, host) | |
| 276 | + | if err != nil { | |
| 277 | + | return nil, err | |
| 278 | + | } | |
| 279 | + | if len(addrs) == 0 { | |
| 280 | + | return nil, fmt.Errorf("fetch image: private host %q not allowed", host) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | var lastErr error | |
| 284 | + | for _, addr := range addrs { | |
| 285 | + | conn, dialErr := dial(ctx, network, net.JoinHostPort(addr.IP.String(), imagePort)) | |
| 286 | + | if dialErr == nil { | |
| 287 | + | return conn, nil | |
| 288 | + | } | |
| 289 | + | lastErr = dialErr | |
| 290 | + | } | |
| 291 | + | if lastErr == nil { | |
| 292 | + | return nil, fmt.Errorf("fetch image: dial host %q failed", host) | |
| 293 | + | } | |
| 294 | + | return nil, fmt.Errorf("fetch image: dial host %q: %w", host, lastErr) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | type validatedImageRoundTripper struct { | |
| 298 | + | base http.RoundTripper | |
| 299 | + | validator func(context.Context, *url.URL) error | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func (t *validatedImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 303 | + | if t == nil || t.base == nil { | |
| 304 | + | return nil, fmt.Errorf("fetch image: transport not available") | |
| 305 | + | } | |
| 306 | + | if err := t.validator(req.Context(), req.URL); err != nil { | |
| 307 | + | return nil, err | |
| 308 | + | } | |
| 309 | + | return t.base.RoundTrip(req) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func isPrivateImageIP(ip net.IP) bool { | |
| 313 | + | return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func validatedImageMimeType(raw string) string { | |
| 317 | + | if raw == "" { | |
| 318 | + | return "" | |
| 319 | + | } | |
| 320 | + | mediaType, _, err := mime.ParseMediaType(raw) | |
| 321 | + | if err != nil { | |
| 322 | + | return "" | |
| 323 | + | } | |
| 324 | + | mediaType = strings.ToLower(mediaType) | |
| 325 | + | if !strings.HasPrefix(mediaType, "image/") { | |
| 326 | + | return "" | |
| 327 | + | } | |
| 328 | + | return mediaType | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Compile-time check that contextMenuResolver implements port.ImageDataResolver. | |
| 332 | + | var _ port.ImageDataResolver = (*contextMenuResolver)(nil) | |
| 333 | + | ||
| 334 | + | // buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext. | |
| 335 | + | func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext { | |
| 336 | + | ctx := port.MenuContext{ | |
| 337 | + | X: x, | |
| 338 | + | Y: y, | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | if wv != nil { | |
| 342 | + | ctx.PageURI = wv.URI() | |
| 343 | + | ctx.CanGoBack = wv.CanGoBack() | |
| 344 | + | ctx.CanGoForward = wv.CanGoForward() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | if hit == nil { | |
| 348 | + | return ctx | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | if hit.ContextIsLink() { | |
| 352 | + | ctx.LinkURI = hit.GetLinkUri() | |
| 353 | + | } | |
| 354 | + | if hit.ContextIsImage() { | |
| 355 | + | ctx.ImageURI = hit.GetImageUri() | |
| 356 | + | } | |
| 357 | + | ctx.HasSelection = hit.ContextIsSelection() | |
| 358 | + | ctx.IsEditable = hit.ContextIsEditable() | |
| 359 | + | ||
| 360 | + | return ctx | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // contextMenuPipeline holds the dependencies needed to run the shared | |
| 364 | + | // context menu flow from inside a WebKit context-menu signal handler. | |
| 365 | + | type contextMenuPipeline struct { | |
| 366 | + | builder port.ContextMenuBuilder | |
| 367 | + | executorFactory port.ContextMenuActionExecutorFactory | |
| 368 | + | clipboard port.Clipboard | |
| 369 | + | onCopied func(textLen int) | |
| 370 | + | resolver port.ImageDataResolver | |
| 371 | + | saver port.ResolvedImageSaver | |
| 372 | + | renderer *Renderer | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExecutor { | |
| 376 | + | if p == nil || p.executorFactory == nil { | |
| 377 | + | return nil | |
| 378 | + | } | |
| 379 | + | clipboard := p.clipboard | |
| 380 | + | if clipboard != nil && p.onCopied != nil { | |
| 381 | + | clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied} | |
| 382 | + | } | |
| 383 | + | return p.executorFactory.NewContextMenuActionExecutor( | |
| 384 | + | clipboard, | |
| 385 | + | p.resolver, | |
| 386 | + | p.saver, | |
| 387 | + | &webkitMenuDelegator{wv: wv}, | |
| 388 | + | ) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | var _ port.Clipboard = (*clipboardCopyNotifier)(nil) | |
| 392 | + | ||
| 393 | + | type clipboardCopyNotifier struct { | |
| 394 | + | clipboard port.Clipboard | |
| 395 | + | onCopied func(textLen int) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error { | |
| 399 | + | if c == nil || c.clipboard == nil { | |
| 400 | + | return fmt.Errorf("clipboard not available") | |
| 401 | + | } | |
| 402 | + | if err := c.clipboard.WriteText(ctx, text); err != nil { | |
| 403 | + | return err | |
| 404 | + | } | |
| 405 | + | if c.onCopied != nil { | |
| 406 | + | c.onCopied(utf8.RuneCountInString(text)) | |
| 407 | + | } | |
| 408 | + | return nil | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 412 | + | if c == nil || c.clipboard == nil { | |
| 413 | + | return fmt.Errorf("clipboard not available") | |
| 414 | + | } | |
| 415 | + | return c.clipboard.WriteImage(ctx, image) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) { | |
| 419 | + | if c == nil || c.clipboard == nil { | |
| 420 | + | return "", fmt.Errorf("clipboard not available") | |
| 421 | + | } | |
| 422 | + | return c.clipboard.ReadText(ctx) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (c *clipboardCopyNotifier) Clear(ctx context.Context) error { | |
| 426 | + | if c == nil || c.clipboard == nil { | |
| 427 | + | return fmt.Errorf("clipboard not available") | |
| 428 | + | } | |
| 429 | + | return c.clipboard.Clear(ctx) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) { | |
| 433 | + | if c == nil || c.clipboard == nil { | |
| 434 | + | return false, fmt.Errorf("clipboard not available") | |
| 435 | + | } | |
| 436 | + | return c.clipboard.HasText(ctx) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |
| 440 | + | // shared context menu pipeline. When the user right-clicks, WebKit's native | |
| 441 | + | // menu is suppressed and replaced by the shared GTK popover menu. | |
| 442 | + | func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |
| 443 | + | if pipeline == nil { | |
| 444 | + | return | |
| 445 | + | } | |
| 446 | + | wv.contextMenu = pipeline | |
| 447 | + | ||
| 448 | + | contextMenuCb := func(_ webkit.WebView, contextMenuPtr uintptr, hitTestPtr uintptr) bool { | |
| 449 | + | var contextMenu *webkit.ContextMenu | |
| 450 | + | if contextMenuPtr != 0 { | |
| 451 | + | contextMenu = webkit.ContextMenuNewFromInternalPtr(contextMenuPtr) | |
| 452 | + | } | |
| 453 | + | var hit *webkit.HitTestResult | |
| 454 | + | if hitTestPtr != 0 { | |
| 455 | + | hit = webkit.HitTestResultNewFromInternalPtr(hitTestPtr) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | x, y := contextMenuPosition(contextMenu) | |
| 459 | + | parent := anchorParentWidget(&wv.inner.Widget) | |
| 460 | + | wv.logger.Debug(). | |
| 461 | + | Int("raw_x", x). | |
| 462 | + | Int("raw_y", y). | |
| 463 | + | Int("anchor_width", wv.inner.Widget.GetAllocatedWidth()). | |
| 464 | + | Int("anchor_height", wv.inner.Widget.GetAllocatedHeight()). | |
| 465 | + | Int("parent_width", widgetAllocatedWidth(parent)). | |
| 466 | + | Int("parent_height", widgetAllocatedHeight(parent)). | |
| 467 | + | Msg("webkit: context menu popup request") | |
| 468 | + | ||
| 469 | + | menuContext := buildMenuContextFromHitTest(wv, hit, x, y) | |
| 470 | + | ctx := logging.WithContext(context.Background(), wv.logger) | |
| 471 | + | if pipeline.builder == nil || pipeline.renderer == nil { | |
| 472 | + | return false | |
| 473 | + | } | |
| 474 | + | items := pipeline.builder.Build(ctx, menuContext) | |
| 475 | + | if len(items) == 0 { | |
| 476 | + | return false | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | executor := pipeline.newExecutor(wv) | |
| 480 | + | if executor == nil { | |
| 481 | + | return false | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // Get the widget for anchoring the popover. | |
| 485 | + | if wv.inner == nil { | |
| 486 | + | return false | |
| 487 | + | } | |
| 488 | + | anchor := &wv.inner.Widget | |
| 489 | + | ||
| 490 | + | pipeline.renderer.Show( | |
| 491 | + | items, | |
| 492 | + | anchor, | |
| 493 | + | int32(x), | |
| 494 | + | int32(y), | |
| 495 | + | func(item port.MenuItem) { | |
| 496 | + | wv.executeContextMenuAction(ctx, executor, item, menuContext) | |
| 497 | + | }, | |
| 498 | + | nil, // onClose — no action needed | |
| 499 | + | ) | |
| 500 | + | ||
| 501 | + | return true // suppress native WebKit context menu | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | sigID := wv.inner.ConnectContextMenu(&contextMenuCb) | |
| 505 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func contextMenuPosition(menu *webkit.ContextMenu) (int, int) { | |
| 509 | + | if menu == nil || menu.GoPointer() == 0 { | |
| 510 | + | return 0, 0 | |
| 511 | + | } | |
| 512 | + | event := menu.GetEvent() | |
| 513 | + | if event == nil { | |
| 514 | + | return 0, 0 | |
| 515 | + | } | |
| 516 | + | var x, y float64 | |
| 517 | + | if !event.GetPosition(&x, &y) { | |
| 518 | + | return 0, 0 | |
| 519 | + | } | |
| 520 | + | return int(x), int(y) | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func anchorParentWidget(anchor *gtk.Widget) *gtk.Widget { | |
| 524 | + | if anchor == nil { | |
| 525 | + | return nil | |
| 526 | + | } | |
| 527 | + | return anchor.GetParent() | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | func widgetAllocatedWidth(widget *gtk.Widget) int { | |
| 531 | + | if widget == nil { | |
| 532 | + | return 0 | |
| 533 | + | } | |
| 534 | + | return widget.GetAllocatedWidth() | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | func widgetAllocatedHeight(widget *gtk.Widget) int { | |
| 538 | + | if widget == nil { | |
| 539 | + | return 0 | |
| 540 | + | } | |
| 541 | + | return widget.GetAllocatedHeight() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // executeContextMenuAction runs the selected menu action through the shared | |
| 545 | + | // executor. GTK-thread-sensitive actions run inline; image fetch/save work | |
| 546 | + | // stays off-thread. | |
| 547 | + | func (wv *WebView) executeContextMenuAction( | |
| 548 | + | ctx context.Context, | |
| 549 | + | executor port.ContextMenuActionExecutor, | |
| 550 | + | item port.MenuItem, | |
| 551 | + | menuContext port.MenuContext, | |
| 552 | + | ) { | |
| 553 | + | if executor == nil { | |
| 554 | + | return | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | if needsBackgroundContextMenuAction(item.Action) { | |
| 558 | + | go wv.runContextMenuAction(ctx, executor, item.Action, menuContext) | |
| 559 | + | return | |
| 560 | + | } | |
| 561 | + | wv.runContextMenuAction(ctx, executor, item.Action, menuContext) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func (wv *WebView) runContextMenuAction( | |
| 565 | + | ctx context.Context, | |
| 566 | + | executor port.ContextMenuActionExecutor, | |
| 567 | + | action port.MenuAction, | |
| 568 | + | menuContext port.MenuContext, | |
| 569 | + | ) { | |
| 570 | + | if err := executor.ExecuteMenuAction(ctx, action, menuContext); err != nil { | |
| 571 | + | wv.logger.Warn().Err(err). | |
| 572 | + | Str("action", string(action)). | |
| 573 | + | Msg("context menu action failed") | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | func needsBackgroundContextMenuAction(action port.MenuAction) bool { | |
| 578 | + | switch action { | |
| 579 | + | case port.MenuActionCopyImage, port.MenuActionSaveImage: | |
| 580 | + | return true | |
| 581 | + | default: | |
| 582 | + | return false | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | type webkitMenuDelegator struct { | |
| 587 | + | wv *WebView | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | var _ port.MenuActionDelegator = (*webkitMenuDelegator)(nil) | |
| 591 | + | ||
| 592 | + | func (d *webkitMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 593 | + | if d == nil || d.wv == nil { | |
| 594 | + | return fmt.Errorf("webkit menu delegator: webview not available") | |
| 595 | + | } | |
| 596 | + | switch action { | |
| 597 | + | case port.MenuActionBack: | |
| 598 | + | return d.wv.GoBack(ctx) | |
| 599 | + | case port.MenuActionForward: | |
| 600 | + | return d.wv.GoForward(ctx) | |
| 601 | + | case port.MenuActionReload: | |
| 602 | + | return d.wv.Reload(ctx) | |
| 603 | + | case port.MenuActionOpenLinkNewTab: | |
| 604 | + | if menuContext.LinkURI == "" { | |
| 605 | + | return fmt.Errorf("open link in new tab: link URI not available") | |
| 606 | + | } | |
| 607 | + | if d.wv.OnLinkMiddleClick == nil { | |
| 608 | + | return fmt.Errorf("open link in new tab: middle-click handler not available") | |
| 609 | + | } | |
| 610 | + | if !d.wv.OnLinkMiddleClick(menuContext.LinkURI) { | |
| 611 | + | return fmt.Errorf("open link in new tab: action not handled") | |
| 612 | + | } | |
| 613 | + | return nil | |
| 614 | + | case port.MenuActionInspectElement: | |
| 615 | + | return d.wv.ShowDevTools() | |
| 616 | + | case port.MenuActionCopySelection: | |
| 617 | + | if !menuContext.HasSelection { | |
| 618 | + | return fmt.Errorf("copy selection: selection not available") | |
| 619 | + | } | |
| 620 | + | d.wv.RunJavaScript(ctx, "document.execCommand('copy');") | |
| 621 | + | return nil | |
| 622 | + | default: | |
| 623 | + | return fmt.Errorf("webkit menu delegator: unsupported action %s", action) | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | // NewContextMenuResolver creates a new ImageDataResolver for WebKit | |
| 628 | + | // that fetches image bytes over HTTP. | |
| 629 | + | func NewContextMenuResolver() port.ImageDataResolver { | |
| 630 | + | return &contextMenuResolver{} | |
| 631 | + | } | |
A internal/infrastructure/webkit/context_menu_test.go +416 -0
| @@ -0,0 +1,416 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "testing" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 16 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // stubHitTest simulates a WebKit HitTestResult for testing. | |
| 23 | + | type stubHitTest struct { | |
| 24 | + | linkURI string | |
| 25 | + | imageURI string | |
| 26 | + | isLink bool | |
| 27 | + | isImage bool | |
| 28 | + | isEditable bool | |
| 29 | + | isSelection bool | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestBuildMenuContextFromHitTest(t *testing.T) { | |
| 33 | + | t.Run("image hit test populates ImageURI", func(t *testing.T) { | |
| 34 | + | hit := stubHitTest{ | |
| 35 | + | imageURI: "https://example.com/photo.png", | |
| 36 | + | isImage: true, | |
| 37 | + | } | |
| 38 | + | ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200) | |
| 39 | + | ||
| 40 | + | assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI) | |
| 41 | + | assert.Equal(t, 100, ctx.X) | |
| 42 | + | assert.Equal(t, 200, ctx.Y) | |
| 43 | + | assert.True(t, ctx.CanGoBack) | |
| 44 | + | assert.False(t, ctx.CanGoForward) | |
| 45 | + | }) | |
| 46 | + | ||
| 47 | + | t.Run("link hit test populates LinkURI", func(t *testing.T) { | |
| 48 | + | hit := stubHitTest{ | |
| 49 | + | linkURI: "https://example.com/page", | |
| 50 | + | isLink: true, | |
| 51 | + | } | |
| 52 | + | ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75) | |
| 53 | + | ||
| 54 | + | assert.Equal(t, "https://example.com/page", ctx.LinkURI) | |
| 55 | + | assert.Empty(t, ctx.ImageURI) | |
| 56 | + | assert.False(t, ctx.CanGoBack) | |
| 57 | + | assert.True(t, ctx.CanGoForward) | |
| 58 | + | }) | |
| 59 | + | ||
| 60 | + | t.Run("editable and selection flags propagate", func(t *testing.T) { | |
| 61 | + | hit := stubHitTest{ | |
| 62 | + | isEditable: true, | |
| 63 | + | isSelection: true, | |
| 64 | + | } | |
| 65 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |
| 66 | + | ||
| 67 | + | assert.True(t, ctx.IsEditable) | |
| 68 | + | assert.True(t, ctx.HasSelection) | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | t.Run("combined image and link", func(t *testing.T) { | |
| 72 | + | hit := stubHitTest{ | |
| 73 | + | linkURI: "https://example.com/link", | |
| 74 | + | imageURI: "https://example.com/img.jpg", | |
| 75 | + | isLink: true, | |
| 76 | + | isImage: true, | |
| 77 | + | } | |
| 78 | + | ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20) | |
| 79 | + | ||
| 80 | + | assert.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 81 | + | assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI) | |
| 82 | + | assert.True(t, ctx.CanGoBack) | |
| 83 | + | assert.True(t, ctx.CanGoForward) | |
| 84 | + | }) | |
| 85 | + | ||
| 86 | + | t.Run("empty hit test produces empty context", func(t *testing.T) { | |
| 87 | + | hit := stubHitTest{} | |
| 88 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |
| 89 | + | ||
| 90 | + | assert.Empty(t, ctx.LinkURI) | |
| 91 | + | assert.Empty(t, ctx.ImageURI) | |
| 92 | + | assert.False(t, ctx.HasSelection) | |
| 93 | + | assert.False(t, ctx.IsEditable) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestResolveImageData(t *testing.T) { | |
| 98 | + | t.Run("rejects empty image URI", func(t *testing.T) { | |
| 99 | + | resolver := &contextMenuResolver{} | |
| 100 | + | _, err := resolver.ResolveImageData(context.Background(), "") | |
| 101 | + | require.Error(t, err) | |
| 102 | + | assert.Contains(t, err.Error(), "empty image URI") | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | t.Run("rejects unsupported URI scheme", func(t *testing.T) { | |
| 106 | + | resolver := &contextMenuResolver{} | |
| 107 | + | _, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png") | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.Contains(t, err.Error(), "unsupported URI scheme") | |
| 110 | + | }) | |
| 111 | + | ||
| 112 | + | t.Run("decodes data URI images", func(t *testing.T) { | |
| 113 | + | imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |
| 114 | + | uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes) | |
| 115 | + | ||
| 116 | + | resolver := &contextMenuResolver{} | |
| 117 | + | data, err := resolver.ResolveImageData(context.Background(), uri) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | assert.Equal(t, imageBytes, data.Bytes) | |
| 120 | + | assert.Equal(t, "image/png", data.MimeType) | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | t.Run("fetches image bytes from HTTP", func(t *testing.T) { | |
| 124 | + | imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |
| 125 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 126 | + | w.Header().Set("Content-Type", "image/jpeg; charset=utf-8") | |
| 127 | + | w.Write(imageBytes) | |
| 128 | + | })) | |
| 129 | + | defer srv.Close() | |
| 130 | + | ||
| 131 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 132 | + | data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png") | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | assert.Equal(t, imageBytes, data.Bytes) | |
| 135 | + | assert.Equal(t, "image/png", data.MimeType) | |
| 136 | + | }) | |
| 137 | + | ||
| 138 | + | t.Run("rejects DNS lookup errors", func(t *testing.T) { | |
| 139 | + | resolver := &contextMenuResolver{ | |
| 140 | + | lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) { | |
| 141 | + | return nil, errors.New("dns failed") | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png") | |
| 146 | + | require.Error(t, err) | |
| 147 | + | assert.Contains(t, err.Error(), "dns failed") | |
| 148 | + | }) | |
| 149 | + | ||
| 150 | + | t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) { | |
| 151 | + | imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |
| 152 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 153 | + | w.Header().Set("Content-Type", "application/octet-stream") | |
| 154 | + | w.Write(imageBytes) | |
| 155 | + | })) | |
| 156 | + | defer srv.Close() | |
| 157 | + | ||
| 158 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 159 | + | data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png") | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | assert.Equal(t, imageBytes, data.Bytes) | |
| 162 | + | assert.Equal(t, "image/png", data.MimeType) | |
| 163 | + | }) | |
| 164 | + | ||
| 165 | + | t.Run("rejects DNS-resolved private hosts", func(t *testing.T) { | |
| 166 | + | resolver := &contextMenuResolver{ | |
| 167 | + | lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) { | |
| 168 | + | return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil | |
| 169 | + | }, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png") | |
| 173 | + | require.Error(t, err) | |
| 174 | + | assert.Contains(t, err.Error(), "private host") | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) { | |
| 178 | + | var dialed []string | |
| 179 | + | resolver := &contextMenuResolver{ | |
| 180 | + | lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) { | |
| 181 | + | return []net.IPAddr{ | |
| 182 | + | {IP: net.ParseIP("127.0.0.1")}, | |
| 183 | + | {IP: net.ParseIP("93.184.216.34")}, | |
| 184 | + | }, nil | |
| 185 | + | }, | |
| 186 | + | dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) { | |
| 187 | + | dialed = append(dialed, addr) | |
| 188 | + | conn, peer := net.Pipe() | |
| 189 | + | t.Cleanup(func() { | |
| 190 | + | _ = conn.Close() | |
| 191 | + | _ = peer.Close() | |
| 192 | + | }) | |
| 193 | + | return conn, nil | |
| 194 | + | }, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | require.NotNil(t, conn) | |
| 200 | + | assert.Equal(t, []string{"93.184.216.34:443"}, dialed) | |
| 201 | + | }) | |
| 202 | + | ||
| 203 | + | t.Run("rejects private literal addresses at connect time", func(t *testing.T) { | |
| 204 | + | resolver := &contextMenuResolver{} | |
| 205 | + | ||
| 206 | + | _, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443") | |
| 207 | + | require.Error(t, err) | |
| 208 | + | assert.Contains(t, err.Error(), "private host") | |
| 209 | + | }) | |
| 210 | + | ||
| 211 | + | t.Run("redirect check rejects private targets", func(t *testing.T) { | |
| 212 | + | resolver := &contextMenuResolver{ | |
| 213 | + | lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) { | |
| 214 | + | return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil | |
| 215 | + | }, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil) | |
| 221 | + | require.Error(t, err) | |
| 222 | + | assert.Contains(t, err.Error(), "private host") | |
| 223 | + | }) | |
| 224 | + | ||
| 225 | + | t.Run("rejects non-image HTTP content", func(t *testing.T) { | |
| 226 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 227 | + | w.Header().Set("Content-Type", "text/html") | |
| 228 | + | w.Write([]byte("<html><body>not an image</body></html>")) | |
| 229 | + | })) | |
| 230 | + | defer srv.Close() | |
| 231 | + | ||
| 232 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 233 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html") | |
| 234 | + | require.Error(t, err) | |
| 235 | + | assert.Contains(t, err.Error(), "not an image") | |
| 236 | + | }) | |
| 237 | + | ||
| 238 | + | t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) { | |
| 239 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 240 | + | w.Header().Set("Content-Type", "image/png") | |
| 241 | + | w.Write([]byte("<html><body>not really an image</body></html>")) | |
| 242 | + | })) | |
| 243 | + | defer srv.Close() | |
| 244 | + | ||
| 245 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 246 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png") | |
| 247 | + | require.Error(t, err) | |
| 248 | + | assert.Contains(t, err.Error(), "not an image") | |
| 249 | + | assert.Contains(t, err.Error(), "text/html") | |
| 250 | + | assert.Contains(t, err.Error(), "image/png") | |
| 251 | + | assert.Contains(t, err.Error(), "/spoofed.png") | |
| 252 | + | }) | |
| 253 | + | ||
| 254 | + | t.Run("returns error on HTTP failure", func(t *testing.T) { | |
| 255 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 256 | + | w.WriteHeader(http.StatusNotFound) | |
| 257 | + | })) | |
| 258 | + | defer srv.Close() | |
| 259 | + | ||
| 260 | + | resolver := &contextMenuResolver{allowPrivateHosts: true} | |
| 261 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png") | |
| 262 | + | require.Error(t, err) | |
| 263 | + | assert.Contains(t, err.Error(), "404") | |
| 264 | + | }) | |
| 265 | + | ||
| 266 | + | t.Run("rejects oversized bodies", func(t *testing.T) { | |
| 267 | + | resolver := &contextMenuResolver{ | |
| 268 | + | allowPrivateHosts: true, | |
| 269 | + | client: &http.Client{ | |
| 270 | + | Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { | |
| 271 | + | return &http.Response{ | |
| 272 | + | StatusCode: http.StatusOK, | |
| 273 | + | Header: http.Header{"Content-Type": []string{"image/png"}}, | |
| 274 | + | Body: io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)), | |
| 275 | + | }, nil | |
| 276 | + | }), | |
| 277 | + | }, | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | _, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png") | |
| 281 | + | require.Error(t, err) | |
| 282 | + | assert.Contains(t, err.Error(), "too large") | |
| 283 | + | }) | |
| 284 | + | ||
| 285 | + | t.Run("returns error on invalid URL", func(t *testing.T) { | |
| 286 | + | resolver := &contextMenuResolver{} | |
| 287 | + | _, err := resolver.ResolveImageData(context.Background(), "://bad-url") | |
| 288 | + | require.Error(t, err) | |
| 289 | + | }) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) { | |
| 293 | + | var gotURI string | |
| 294 | + | wv := &WebView{ | |
| 295 | + | OnLinkMiddleClick: func(uri string) bool { | |
| 296 | + | gotURI = uri | |
| 297 | + | return true | |
| 298 | + | }, | |
| 299 | + | } | |
| 300 | + | delegator := &webkitMenuDelegator{wv: wv} | |
| 301 | + | ||
| 302 | + | err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 303 | + | require.NoError(t, err) | |
| 304 | + | require.Equal(t, "https://example.com/new", gotURI) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) { | |
| 308 | + | wv := &WebView{ | |
| 309 | + | OnLinkMiddleClick: func(string) bool { | |
| 310 | + | return false | |
| 311 | + | }, | |
| 312 | + | } | |
| 313 | + | delegator := &webkitMenuDelegator{wv: wv} | |
| 314 | + | ||
| 315 | + | err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 316 | + | require.Error(t, err) | |
| 317 | + | assert.Contains(t, err.Error(), "action not handled") | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |
| 321 | + | var copiedLen int | |
| 322 | + | ctx := context.Background() | |
| 323 | + | baseClipboard := portmocks.NewMockClipboard(t) | |
| 324 | + | factory := portmocks.NewMockContextMenuActionExecutorFactory(t) | |
| 325 | + | var capturedClipboard port.Clipboard | |
| 326 | + | factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 327 | + | func( | |
| 328 | + | clipboard port.Clipboard, | |
| 329 | + | resolver port.ImageDataResolver, | |
| 330 | + | saver port.ResolvedImageSaver, | |
| 331 | + | delegator port.MenuActionDelegator, | |
| 332 | + | ) port.ContextMenuActionExecutor { | |
| 333 | + | capturedClipboard = clipboard | |
| 334 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 335 | + | }, | |
| 336 | + | ).Once() | |
| 337 | + | pipeline := &contextMenuPipeline{ | |
| 338 | + | clipboard: baseClipboard, | |
| 339 | + | onCopied: func(textLen int) { copiedLen = textLen }, | |
| 340 | + | executorFactory: factory, | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | executor := pipeline.newExecutor(&WebView{}) | |
| 344 | + | require.NotNil(t, executor) | |
| 345 | + | ||
| 346 | + | t.Run("copy link", func(t *testing.T) { | |
| 347 | + | copiedLen = 0 | |
| 348 | + | require.NotNil(t, capturedClipboard) | |
| 349 | + | baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once() | |
| 350 | + | ||
| 351 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 352 | + | require.NoError(t, err) | |
| 353 | + | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | |
| 354 | + | }) | |
| 355 | + | ||
| 356 | + | t.Run("copy selection", func(t *testing.T) { | |
| 357 | + | copiedLen = 0 | |
| 358 | + | require.NotNil(t, capturedClipboard) | |
| 359 | + | baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once() | |
| 360 | + | ||
| 361 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | |
| 364 | + | }) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |
| 368 | + | // that works with our stub type instead of requiring real WebKit objects. | |
| 369 | + | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | |
| 370 | + | ctx := port.MenuContext{ | |
| 371 | + | CanGoBack: canGoBack, | |
| 372 | + | CanGoForward: canGoForward, | |
| 373 | + | X: x, | |
| 374 | + | Y: y, | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | if hit.isLink { | |
| 378 | + | ctx.LinkURI = hit.linkURI | |
| 379 | + | } | |
| 380 | + | if hit.isImage { | |
| 381 | + | ctx.ImageURI = hit.imageURI | |
| 382 | + | } | |
| 383 | + | ctx.HasSelection = hit.isSelection | |
| 384 | + | ctx.IsEditable = hit.isEditable | |
| 385 | + | ||
| 386 | + | return ctx | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | type roundTripperFunc func(*http.Request) (*http.Response, error) | |
| 390 | + | ||
| 391 | + | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 392 | + | return f(req) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | type sizedByteReader struct { | |
| 396 | + | remaining int | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func newSizedByteReader(size int) *sizedByteReader { | |
| 400 | + | return &sizedByteReader{remaining: size} | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func (r *sizedByteReader) Read(p []byte) (int, error) { | |
| 404 | + | if r.remaining <= 0 { | |
| 405 | + | return 0, io.EOF | |
| 406 | + | } | |
| 407 | + | n := len(p) | |
| 408 | + | if n > r.remaining { | |
| 409 | + | n = r.remaining | |
| 410 | + | } | |
| 411 | + | for i := 0; i < n; i++ { | |
| 412 | + | p[i] = 'x' | |
| 413 | + | } | |
| 414 | + | r.remaining -= n | |
| 415 | + | return n, nil | |
| 416 | + | } | |
M internal/infrastructure/webkit/download_handler.go +114 -87
| @@ -3,89 +3,90 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "os" | |
| 7 | 6 | "sync" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 9 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 12 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | const ( | |
| 17 | - | // dirPerm is the permission mode for creating download directories. | |
| 18 | - | dirPerm = 0755 | |
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 19 | 14 | ) | |
| 20 | 15 | ||
| 21 | 16 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | |
| 22 | 17 | type DownloadHandler struct { | |
| 23 | - | downloadPath string | |
| 24 | - | eventHandler port.DownloadEventHandler | |
| 25 | - | preparer port.DownloadPreparer | |
| 26 | - | mu sync.RWMutex | |
| 18 | + | runtime *downloadruntime.Runtime | |
| 19 | + | mu sync.RWMutex | |
| 27 | 20 | } | |
| 28 | 21 | ||
| 29 | 22 | // NewDownloadHandler creates a new download handler. | |
| 30 | - | // Panics if preparer is nil (fail fast on misconfiguration). | |
| 23 | + | // The caller (ConfigureDownloads) must validate that preparer is non-nil. | |
| 31 | 24 | func NewDownloadHandler( | |
| 32 | 25 | downloadPath string, | |
| 33 | 26 | handler port.DownloadEventHandler, | |
| 34 | 27 | preparer port.DownloadPreparer, | |
| 35 | 28 | ) *DownloadHandler { | |
| 36 | - | if preparer == nil { | |
| 37 | - | panic("preparer is required") | |
| 38 | - | } | |
| 39 | 29 | return &DownloadHandler{ | |
| 40 | - | downloadPath: downloadPath, | |
| 41 | - | eventHandler: handler, | |
| 42 | - | preparer: preparer, | |
| 30 | + | runtime: downloadruntime.New(downloadPath, handler, preparer), | |
| 43 | 31 | } | |
| 44 | 32 | } | |
| 45 | 33 | ||
| 46 | 34 | // downloadState tracks the state of a single download to prevent duplicate notifications. | |
| 47 | 35 | // This is allocated per-download to ensure proper isolation between concurrent downloads. | |
| 48 | 36 | type downloadState struct { | |
| 49 | - | failed bool | |
| 50 | - | mu sync.Mutex | |
| 37 | + | failed bool | |
| 38 | + | filename string | |
| 39 | + | destination string | |
| 40 | + | lastProgressPercent int | |
| 41 | + | mu sync.Mutex | |
| 51 | 42 | } | |
| 52 | 43 | ||
| 53 | 44 | // HandleDownload sets up signal handlers for a new download. | |
| 54 | 45 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | |
| 55 | - | log := logging.FromContext(ctx) | |
| 46 | + | if download == nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 56 | 49 | ||
| 57 | 50 | h.mu.RLock() | |
| 58 | - | downloadPath := h.downloadPath | |
| 59 | - | eventHandler := h.eventHandler | |
| 60 | - | preparer := h.preparer | |
| 51 | + | runtime := h.runtime | |
| 61 | 52 | h.mu.RUnlock() | |
| 62 | 53 | ||
| 63 | 54 | // Track download state in a dedicated struct to ensure proper isolation. | |
| 64 | 55 | // Each download gets its own state to prevent interference between concurrent downloads. | |
| 65 | - | state := &downloadState{} | |
| 66 | - | ||
| 67 | - | // Handle decide-destination signal to set download path. | |
| 68 | - | //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously) | |
| 69 | - | decideDestCb := func(d webkit.Download, suggestedFilename string) bool { | |
| 70 | - | // Ensure download directory exists. | |
| 71 | - | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | |
| 72 | - | log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory") | |
| 73 | - | d.Cancel() | |
| 74 | - | return false | |
| 75 | - | } | |
| 56 | + | state := &downloadState{lastProgressPercent: -1} | |
| 57 | + | ||
| 58 | + | decideDestCb := makeDecideDestinationCallback(ctx, runtime, state) | |
| 59 | + | download.ConnectDecideDestination(&decideDestCb) | |
| 60 | + | ||
| 61 | + | progressCb := makeProgressCallback(ctx, runtime, state) | |
| 62 | + | download.ConnectReceivedData(&progressCb) | |
| 63 | + | ||
| 64 | + | failedCb := makeFailedCallback(ctx, runtime, state) | |
| 65 | + | download.ConnectFailed(&failedCb) | |
| 66 | + | ||
| 67 | + | finishedCb := makeFinishedCallback(ctx, runtime, state) | |
| 68 | + | download.ConnectFinished(&finishedCb) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func makeDecideDestinationCallback( | |
| 72 | + | ctx context.Context, | |
| 73 | + | runtime *downloadruntime.Runtime, | |
| 74 | + | state *downloadState, | |
| 75 | + | ) func(webkit.Download, string) bool { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 76 | 77 | ||
| 77 | - | // Wrap WebKit response as port.DownloadResponse | |
| 78 | + | return func(d webkit.Download, suggestedFilename string) bool { | |
| 78 | 79 | var response port.DownloadResponse | |
| 79 | 80 | if resp := d.GetResponse(); resp != nil { | |
| 80 | 81 | response = &uriResponseAdapter{resp: resp} | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | - | // Use DownloadPreparer to resolve filename | |
| 84 | - | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 85 | - | SuggestedFilename: suggestedFilename, | |
| 86 | - | Response: response, | |
| 87 | - | DownloadDir: downloadPath, | |
| 88 | - | }) | |
| 84 | + | output, err := runtime.ResolveDestination(ctx, suggestedFilename, response) | |
| 85 | + | if err != nil { | |
| 86 | + | log.Error().Err(err).Msg("failed to prepare download destination") | |
| 87 | + | d.Cancel() | |
| 88 | + | return false | |
| 89 | + | } | |
| 89 | 90 | ||
| 90 | 91 | log.Debug(). | |
| 91 | 92 | Str("suggested", suggestedFilename). | |
| @@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 93 | 94 | Str("destPath", output.DestinationPath). | |
| 94 | 95 | Msg("setting download destination") | |
| 95 | 96 | ||
| 96 | - | // WebKit expects an absolute file path for the destination. | |
| 97 | 97 | d.SetDestination(output.DestinationPath) | |
| 98 | 98 | ||
| 99 | - | // Notify: download started. | |
| 100 | - | if eventHandler != nil { | |
| 101 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 102 | - | Type: port.DownloadEventStarted, | |
| 103 | - | Filename: output.Filename, | |
| 104 | - | Destination: output.DestinationPath, | |
| 105 | - | }) | |
| 106 | - | } | |
| 99 | + | state.mu.Lock() | |
| 100 | + | state.filename = output.Filename | |
| 101 | + | state.destination = output.DestinationPath | |
| 102 | + | state.lastProgressPercent = -1 | |
| 103 | + | state.mu.Unlock() | |
| 107 | 104 | ||
| 108 | - | log.Info(). | |
| 109 | - | Str("filename", output.Filename). | |
| 110 | - | Str("destination", output.DestinationPath). | |
| 111 | - | Msg("download started") | |
| 105 | + | runtime.EmitStarted(ctx, output) | |
| 106 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | } | |
| 112 | 110 | ||
| 113 | - | return false // false = we handled it synchronously | |
| 111 | + | func makeProgressCallback( | |
| 112 | + | ctx context.Context, | |
| 113 | + | runtime *downloadruntime.Runtime, | |
| 114 | + | state *downloadState, | |
| 115 | + | ) func(webkit.Download, uint64) { | |
| 116 | + | return func(d webkit.Download, _ uint64) { | |
| 117 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |
| 114 | 118 | } | |
| 115 | - | download.ConnectDecideDestination(&decideDestCb) | |
| 119 | + | } | |
| 116 | 120 | ||
| 117 | - | // Handle failed signal. | |
| 118 | - | failedCb := func(d webkit.Download, gerr *glib.Error) { | |
| 121 | + | func makeFailedCallback( | |
| 122 | + | ctx context.Context, | |
| 123 | + | runtime *downloadruntime.Runtime, | |
| 124 | + | state *downloadState, | |
| 125 | + | ) func(webkit.Download, *glib.Error) { | |
| 126 | + | return func(d webkit.Download, gerr *glib.Error) { | |
| 119 | 127 | state.mu.Lock() | |
| 120 | 128 | state.failed = true | |
| 121 | 129 | state.mu.Unlock() | |
| 122 | 130 | ||
| 123 | 131 | dest := d.GetDestination() | |
| 124 | 132 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 125 | - | ||
| 126 | - | // Create error for the failed download. | |
| 127 | 133 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | |
| 128 | - | ||
| 129 | - | if eventHandler != nil { | |
| 130 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 131 | - | Type: port.DownloadEventFailed, | |
| 132 | - | Filename: filename, | |
| 133 | - | Destination: dest, | |
| 134 | - | Error: downloadErr, | |
| 135 | - | }) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | log.Warn().Str("filename", filename).Msg("download failed") | |
| 134 | + | runtime.EmitFailed(ctx, filename, dest, downloadErr) | |
| 139 | 135 | } | |
| 140 | - | download.ConnectFailed(&failedCb) | |
| 136 | + | } | |
| 141 | 137 | ||
| 142 | - | // Handle finished signal. | |
| 143 | - | finishedCb := func(d webkit.Download) { | |
| 138 | + | func makeFinishedCallback( | |
| 139 | + | ctx context.Context, | |
| 140 | + | runtime *downloadruntime.Runtime, | |
| 141 | + | state *downloadState, | |
| 142 | + | ) func(webkit.Download) { | |
| 143 | + | return func(d webkit.Download) { | |
| 144 | 144 | state.mu.Lock() | |
| 145 | 145 | failed := state.failed | |
| 146 | 146 | state.mu.Unlock() | |
| 147 | - | ||
| 148 | - | // Don't send finished event if we already sent a failed event. | |
| 149 | 147 | if failed { | |
| 150 | 148 | return | |
| 151 | 149 | } | |
| 152 | 150 | ||
| 153 | 151 | dest := d.GetDestination() | |
| 154 | 152 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 153 | + | runtime.EmitFinished(ctx, filename, dest) | |
| 154 | + | } | |
| 155 | + | } | |
| 155 | 156 | ||
| 156 | - | if eventHandler != nil { | |
| 157 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 158 | - | Type: port.DownloadEventFinished, | |
| 159 | - | Filename: filename, | |
| 160 | - | Destination: dest, | |
| 161 | - | }) | |
| 162 | - | } | |
| 157 | + | func emitWebKitDownloadProgress( | |
| 158 | + | ctx context.Context, | |
| 159 | + | runtime *downloadruntime.Runtime, | |
| 160 | + | state *downloadState, | |
| 161 | + | d webkit.Download, | |
| 162 | + | ) { | |
| 163 | + | state.mu.Lock() | |
| 164 | + | if state.failed { | |
| 165 | + | state.mu.Unlock() | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | filename := state.filename | |
| 169 | + | destination := state.destination | |
| 170 | + | lastPercent := state.lastProgressPercent | |
| 171 | + | state.mu.Unlock() | |
| 172 | + | if filename == "" || destination == "" { | |
| 173 | + | return | |
| 174 | + | } | |
| 163 | 175 | ||
| 164 | - | log.Info().Str("filename", filename).Msg("download finished") | |
| 176 | + | progress := d.GetEstimatedProgress() | |
| 177 | + | if progress <= 0 || progress >= 1 { | |
| 178 | + | return | |
| 165 | 179 | } | |
| 166 | - | download.ConnectFinished(&finishedCb) | |
| 180 | + | percent := int(progress * 100) | |
| 181 | + | if percent <= lastPercent { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | state.mu.Lock() | |
| 186 | + | if state.failed || percent <= state.lastProgressPercent { | |
| 187 | + | state.mu.Unlock() | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | state.lastProgressPercent = percent | |
| 191 | + | state.mu.Unlock() | |
| 192 | + | ||
| 193 | + | runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0) | |
| 167 | 194 | } | |
| 168 | 195 | ||
| 169 | 196 | // SetDownloadPath updates the download directory. | |
| 170 | 197 | func (h *DownloadHandler) SetDownloadPath(path string) { | |
| 171 | 198 | h.mu.Lock() | |
| 172 | 199 | defer h.mu.Unlock() | |
| 173 | - | h.downloadPath = path | |
| 200 | + | h.runtime.SetDownloadPath(path) | |
| 174 | 201 | } | |
| 175 | 202 | ||
| 176 | 203 | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. | |
M internal/infrastructure/webkit/download_handler_test.go +11 -6
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestNewDownloadHandler(t *testing.T) { | |
| @@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) { | |||
| 14 | 17 | handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC) | |
| 15 | 18 | ||
| 16 | 19 | assert.NotNil(t, handler) | |
| 17 | - | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | |
| 18 | - | assert.Nil(t, handler.eventHandler) | |
| 19 | - | assert.NotNil(t, handler.preparer) | |
| 20 | + | require.NotNil(t, handler.runtime) | |
| 20 | 21 | }) | |
| 21 | 22 | ||
| 22 | 23 | t.Run("creates handler with custom path", func(t *testing.T) { | |
| 23 | 24 | handler := NewDownloadHandler("/custom/path", nil, prepareUC) | |
| 24 | 25 | ||
| 25 | - | assert.Equal(t, "/custom/path", handler.downloadPath) | |
| 26 | + | require.NotNil(t, handler.runtime) | |
| 26 | 27 | }) | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) { | |||
| 30 | 31 | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | |
| 31 | 32 | handler := NewDownloadHandler("/initial/path", nil, prepareUC) | |
| 32 | 33 | ||
| 33 | - | handler.SetDownloadPath("/new/path") | |
| 34 | + | newPath := t.TempDir() | |
| 35 | + | handler.SetDownloadPath(newPath) | |
| 34 | 36 | ||
| 35 | - | assert.Equal(t, "/new/path", handler.downloadPath) | |
| 37 | + | output, err := handler.runtime.ResolveDestination(context.Background(), "artifact.pdf", nil) | |
| 38 | + | ||
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, filepath.Join(newPath, "artifact.pdf"), output.DestinationPath) | |
| 36 | 41 | } | |
| 37 | 42 | ||
| 38 | 43 | func TestURIResponseAdapter(t *testing.T) { | |
M internal/infrastructure/webkit/engine.go +65 -11
| @@ -13,17 +13,23 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | // Engine implements port.Engine for the WebKit browser engine. | |
| 15 | 15 | type Engine struct { | |
| 16 | - | ctx context.Context | |
| 17 | - | wkCtx *WebKitContext | |
| 18 | - | settings *SettingsManager | |
| 19 | - | injector *ContentInjector | |
| 20 | - | messageRouter *MessageRouter | |
| 21 | - | pool *WebViewPool | |
| 22 | - | factory *WebViewFactory | |
| 23 | - | filterManager *filtering.Manager | |
| 24 | - | schemeHandler *DumbSchemeHandler | |
| 25 | - | schemePath string | |
| 26 | - | logger zerolog.Logger | |
| 16 | + | ctx context.Context | |
| 17 | + | wkCtx *WebKitContext | |
| 18 | + | settings *SettingsManager | |
| 19 | + | injector *ContentInjector | |
| 20 | + | messageRouter *MessageRouter | |
| 21 | + | pool *WebViewPool | |
| 22 | + | factory *WebViewFactory | |
| 23 | + | filterManager *filtering.Manager | |
| 24 | + | schemeHandler *DumbSchemeHandler | |
| 25 | + | schemePath string | |
| 26 | + | logger zerolog.Logger | |
| 27 | + | ctxMenuBuilder port.ContextMenuBuilder | |
| 28 | + | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 29 | + | onClipboardCopied func(textLen int) | |
| 30 | + | downloadPath string | |
| 31 | + | downloadPreparer port.DownloadPreparer | |
| 32 | + | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // Compile-time check that Engine implements port.Engine. | |
| @@ -92,6 +98,9 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||
| 92 | 98 | if e.messageRouter == nil { | |
| 93 | 99 | return fmt.Errorf("message router not initialized") | |
| 94 | 100 | } | |
| 101 | + | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | |
| 102 | + | e.clipboard = deps.Clipboard | |
| 103 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 95 | 104 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | |
| 96 | 105 | } | |
| 97 | 106 | ||
| @@ -111,8 +120,19 @@ func (e *Engine) ConfigureDownloads( | |||
| 111 | 120 | if e.wkCtx == nil { | |
| 112 | 121 | return fmt.Errorf("webkit context not initialized") | |
| 113 | 122 | } | |
| 123 | + | if preparer == nil { | |
| 124 | + | return fmt.Errorf("download preparer is required") | |
| 125 | + | } | |
| 114 | 126 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | |
| 115 | 127 | e.wkCtx.SetDownloadHandler(ctx, handler) | |
| 128 | + | e.downloadPath = downloadPath | |
| 129 | + | e.downloadPreparer = preparer | |
| 130 | + | ||
| 131 | + | // Wire the shared context menu pipeline now that all dependencies are | |
| 132 | + | // available. RegisterHandlers (called earlier) captured the clipboard; | |
| 133 | + | // ConfigureDownloads supplies the download preparer and path. | |
| 134 | + | e.installContextMenuPipeline() | |
| 135 | + | ||
| 116 | 136 | return nil | |
| 117 | 137 | } | |
| 118 | 138 | ||
| @@ -146,3 +166,37 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU | |||
| 146 | 166 | } | |
| 147 | 167 | return nil | |
| 148 | 168 | } | |
| 169 | + | ||
| 170 | + | // installContextMenuPipeline creates the shared context menu pipeline and | |
| 171 | + | // propagates it to the factory (and pool). This is called once during startup, | |
| 172 | + | // after both RegisterHandlers (clipboard) and ConfigureDownloads (download | |
| 173 | + | // deps) have run. Kept on the concrete *Engine type because it is a | |
| 174 | + | // WebKit-specific concern. | |
| 175 | + | func (e *Engine) installContextMenuPipeline() { | |
| 176 | + | resolver := NewContextMenuResolver() | |
| 177 | + | saver := NewResolvedImageSaver(e.downloadPreparer, e.downloadPath) | |
| 178 | + | ||
| 179 | + | // WebKit's context-menu signal fires on the GTK main thread, so no | |
| 180 | + | // dispatch wrapper is needed. | |
| 181 | + | renderer := NewRenderer(nil) | |
| 182 | + | ||
| 183 | + | pipeline := &contextMenuPipeline{ | |
| 184 | + | builder: e.ctxMenuBuilder, | |
| 185 | + | executorFactory: e.ctxMenuExecutorFactory, | |
| 186 | + | clipboard: e.clipboard, | |
| 187 | + | onCopied: e.onClipboardCopied, | |
| 188 | + | resolver: resolver, | |
| 189 | + | saver: saver, | |
| 190 | + | renderer: renderer, | |
| 191 | + | } | |
| 192 | + | e.SetContextMenuPipeline(pipeline) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // SetContextMenuPipeline configures the context menu pipeline for all WebViews | |
| 196 | + | // created by the factory (and pool). Call this after the use cases and renderer | |
| 197 | + | // are available in the bootstrap layer. | |
| 198 | + | func (e *Engine) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 199 | + | if e.factory != nil { | |
| 200 | + | e.factory.SetContextMenuPipeline(pipeline) | |
| 201 | + | } | |
| 202 | + | } | |
M internal/infrastructure/webkit/engine_init.go +31 -15
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 12 | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | "github.com/bnema/dumber/internal/ui/theme" | |
| 15 | 16 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -22,11 +23,14 @@ func NewEngine( | |||
| 22 | 23 | ctx context.Context, | |
| 23 | 24 | cfg *config.Config, | |
| 24 | 25 | opts port.EngineOptions, | |
| 26 | + | profile runtimeprofile.Profile, | |
| 25 | 27 | wkCfg WebKitEngineConfig, | |
| 26 | 28 | currentConfigPayload func() ([]byte, error), | |
| 27 | 29 | defaultConfigPayload func() ([]byte, error), | |
| 28 | 30 | themeManager *theme.Manager, | |
| 29 | 31 | colorResolver port.ColorSchemeResolver, | |
| 32 | + | contextMenuBuilder port.ContextMenuBuilder, | |
| 33 | + | contextMenuExecutorFactory port.ContextMenuActionExecutorFactory, | |
| 30 | 34 | logger zerolog.Logger, | |
| 31 | 35 | ) (*Engine, error) { | |
| 32 | 36 | // --- Hardware survey and performance profile resolution --- | |
| @@ -37,7 +41,7 @@ func NewEngine( | |||
| 37 | 41 | logging.Trace().Mark("render_env") | |
| 38 | 42 | ||
| 39 | 43 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | |
| 40 | - | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | |
| 44 | + | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | |
| 41 | 45 | logger.Info(). | |
| 42 | 46 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | |
| 43 | 47 | Bool("itp_enabled", wkOpts.ITPEnabled). | |
| @@ -50,7 +54,7 @@ func NewEngine( | |||
| 50 | 54 | logging.Trace().Mark("webkit_context") | |
| 51 | 55 | ||
| 52 | 56 | // --- Filter manager --- | |
| 53 | - | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | |
| 57 | + | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | |
| 54 | 58 | ||
| 55 | 59 | // --- Scheme handler --- | |
| 56 | 60 | schemeHandler := NewDumbSchemeHandler(ctx) | |
| @@ -107,17 +111,19 @@ func NewEngine( | |||
| 107 | 111 | ||
| 108 | 112 | // --- Assemble Engine --- | |
| 109 | 113 | engine := &Engine{ | |
| 110 | - | ctx: ctx, | |
| 111 | - | wkCtx: wkCtx, | |
| 112 | - | settings: settings, | |
| 113 | - | injector: injector, | |
| 114 | - | messageRouter: messageRouter, | |
| 115 | - | pool: pool, | |
| 116 | - | factory: factory, | |
| 117 | - | filterManager: filterManager, | |
| 118 | - | schemeHandler: schemeHandler, | |
| 119 | - | schemePath: "dumb://", | |
| 120 | - | logger: logger, | |
| 114 | + | ctx: ctx, | |
| 115 | + | wkCtx: wkCtx, | |
| 116 | + | settings: settings, | |
| 117 | + | injector: injector, | |
| 118 | + | messageRouter: messageRouter, | |
| 119 | + | pool: pool, | |
| 120 | + | factory: factory, | |
| 121 | + | filterManager: filterManager, | |
| 122 | + | schemeHandler: schemeHandler, | |
| 123 | + | schemePath: "dumb://", | |
| 124 | + | logger: logger, | |
| 125 | + | ctxMenuBuilder: contextMenuBuilder, | |
| 126 | + | ctxMenuExecutorFactory: contextMenuExecutorFactory, | |
| 121 | 127 | } | |
| 122 | 128 | ||
| 123 | 129 | return engine, nil | |
| @@ -202,14 +208,24 @@ func engineConfigureRenderingEnvironment( | |||
| 202 | 208 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | |
| 203 | 209 | func engineBuildContextOptions( | |
| 204 | 210 | opts port.EngineOptions, | |
| 211 | + | profile runtimeprofile.Profile, | |
| 205 | 212 | wkCfg WebKitEngineConfig, | |
| 206 | 213 | perfSettings *config.ResolvedPerformanceSettings, | |
| 207 | 214 | ) webKitContextOptions { | |
| 208 | 215 | cp := opts.CookiePolicy // empty preserves runtime default per port contract | |
| 209 | 216 | ||
| 217 | + | dataDir := profile.WebKitDataDir() | |
| 218 | + | if opts.DataDir != "" { | |
| 219 | + | dataDir = opts.DataDir | |
| 220 | + | } | |
| 221 | + | cacheDir := profile.WebKitCacheDir() | |
| 222 | + | if opts.CacheDir != "" { | |
| 223 | + | cacheDir = opts.CacheDir | |
| 224 | + | } | |
| 225 | + | ||
| 210 | 226 | wkOpts := webKitContextOptions{ | |
| 211 | - | DataDir: opts.DataDir, | |
| 212 | - | CacheDir: opts.CacheDir, | |
| 227 | + | DataDir: dataDir, | |
| 228 | + | CacheDir: cacheDir, | |
| 213 | 229 | CookiePolicy: cp, | |
| 214 | 230 | ITPEnabled: wkCfg.ITPEnabled, | |
| 215 | 231 | } | |
A internal/infrastructure/webkit/engine_init_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) { | |
| 14 | + | profile := runtimeprofile.Profile{ | |
| 15 | + | Mode: runtimeprofile.ModeDev, | |
| 16 | + | Engine: "webkit", | |
| 17 | + | EnginePaths: runtimeprofile.EnginePaths{ | |
| 18 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |
| 19 | + | }, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |
| 23 | + | require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir) | |
| 24 | + | require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) { | |
| 28 | + | profile := runtimeprofile.Profile{ | |
| 29 | + | Mode: runtimeprofile.ModeDev, | |
| 30 | + | Engine: "webkit", | |
| 31 | + | EnginePaths: runtimeprofile.EnginePaths{ | |
| 32 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |
| 37 | + | require.Equal(t, "/tmp/data", wkOpts.DataDir) | |
| 38 | + | require.Equal(t, "/tmp/cache", wkOpts.CacheDir) | |
| 39 | + | } | |
M internal/infrastructure/webkit/engine_runtime_test.go +8 -0
| @@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) { | |||
| 46 | 46 | require.Contains(t, err.Error(), "webkit context not initialized") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) { | |
| 50 | + | // After removing the panic, NewDownloadHandler with nil preparer should | |
| 51 | + | // not panic. The caller (ConfigureDownloads) is responsible for validation. | |
| 52 | + | require.NotPanics(t, func() { | |
| 53 | + | _ = NewDownloadHandler("/tmp", nil, nil) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | ||
| 49 | 57 | func TestEngine_Close_NilPool(t *testing.T) { | |
| 50 | 58 | e := &Engine{} | |
| 51 | 59 | err := e.Close() | |
M internal/infrastructure/webkit/factory.go +21 -1
| @@ -17,7 +17,8 @@ type WebViewFactory struct { | |||
| 17 | 17 | pool *WebViewPool | |
| 18 | 18 | injector *ContentInjector | |
| 19 | 19 | router *MessageRouter | |
| 20 | - | filterApplier FilterApplier // Optional content filter applier | |
| 20 | + | filterApplier FilterApplier // Optional content filter applier | |
| 21 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |
| 21 | 22 | ||
| 22 | 23 | // Background color for WebViews (eliminates white flash) | |
| 23 | 24 | bg bgColor | |
| @@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | |||
| 61 | 62 | } | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 65 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |
| 66 | + | // Propagates to the pool if present. | |
| 67 | + | func (f *WebViewFactory) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 68 | + | f.ctxMenu = pipeline | |
| 69 | + | if f.pool != nil { | |
| 70 | + | f.pool.SetContextMenuPipeline(pipeline) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 64 | 74 | // Create creates a new WebView instance. | |
| 65 | 75 | // If a pool is configured, it will try to acquire from the pool first. | |
| 66 | 76 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | |
| @@ -119,6 +129,11 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 119 | 129 | f.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 120 | 130 | } | |
| 121 | 131 | ||
| 132 | + | // Wire context menu if configured | |
| 133 | + | if f.ctxMenu != nil { | |
| 134 | + | wv.connectContextMenuSignal(f.ctxMenu) | |
| 135 | + | } | |
| 136 | + | ||
| 122 | 137 | log.Debug(). | |
| 123 | 138 | Uint64("id", uint64(wv.ID())). | |
| 124 | 139 | Uint64("parent_id", uint64(parentID)). | |
| @@ -152,6 +167,11 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||
| 152 | 167 | f.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 153 | 168 | } | |
| 154 | 169 | ||
| 170 | + | // Wire context menu if configured | |
| 171 | + | if f.ctxMenu != nil { | |
| 172 | + | wv.connectContextMenuSignal(f.ctxMenu) | |
| 173 | + | } | |
| 174 | + | ||
| 155 | 175 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | |
| 156 | 176 | return wv, nil | |
| 157 | 177 | } | |
M internal/infrastructure/webkit/gobject_type_helpers.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/purego" | |
| 8 | 9 | "github.com/bnema/puregotk/pkg/core" | |
| 9 | 10 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | |
| 10 | - | "github.com/ebitengine/purego" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | var ( | |
M internal/infrastructure/webkit/injector.go +143 -7
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | const ( | |
| @@ -59,10 +59,134 @@ const autoCopySelectionScript = `(function() { | |||
| 59 | 59 | }); | |
| 60 | 60 | })();` | |
| 61 | 61 | ||
| 62 | + | // explicitCopyScript captures explicit copy/cut operations and forwards them to Go. | |
| 63 | + | const explicitCopyScript = `(function() { | |
| 64 | + | var pendingCommand = ''; | |
| 65 | + | ||
| 66 | + | function isSafeSelectableInput(activeEl) { | |
| 67 | + | if (!activeEl || activeEl.tagName !== 'INPUT') { | |
| 68 | + | return false; | |
| 69 | + | } | |
| 70 | + | switch (String(activeEl.type || '').toLowerCase()) { | |
| 71 | + | case 'password': | |
| 72 | + | case 'button': | |
| 73 | + | case 'checkbox': | |
| 74 | + | case 'color': | |
| 75 | + | case 'file': | |
| 76 | + | case 'hidden': | |
| 77 | + | case 'image': | |
| 78 | + | case 'radio': | |
| 79 | + | case 'range': | |
| 80 | + | case 'reset': | |
| 81 | + | case 'submit': | |
| 82 | + | return false; | |
| 83 | + | default: | |
| 84 | + | return true; | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | function selectedText() { | |
| 89 | + | var activeEl = document.activeElement; | |
| 90 | + | if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) { | |
| 91 | + | if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') { | |
| 92 | + | var start = activeEl.selectionStart; | |
| 93 | + | var end = activeEl.selectionEnd; | |
| 94 | + | if (end > start) { | |
| 95 | + | return activeEl.value.slice(start, end); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | var selection = window.getSelection ? window.getSelection() : null; | |
| 100 | + | return selection ? selection.toString() : ''; | |
| 101 | + | } | |
| 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 | + | ||
| 117 | + | function postExplicitCopy(action, text) { | |
| 118 | + | if (!text) { | |
| 119 | + | return; | |
| 120 | + | } | |
| 121 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |
| 122 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 123 | + | type: 'explicit_text_copy', | |
| 124 | + | payload: { text: text, action: action } | |
| 125 | + | }); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | function consumePendingAction(fallback) { | |
| 130 | + | var action = pendingCommand || fallback; | |
| 131 | + | pendingCommand = ''; | |
| 132 | + | return action; | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | document.addEventListener('copy', function(event) { | |
| 136 | + | if (event && event.isTrusted === false) { | |
| 137 | + | return; | |
| 138 | + | } | |
| 139 | + | postExplicitCopy(consumePendingAction('copy'), selectedText()); | |
| 140 | + | }, true); | |
| 141 | + | ||
| 142 | + | document.addEventListener('cut', function(event) { | |
| 143 | + | if (event && event.isTrusted === false) { | |
| 144 | + | return; | |
| 145 | + | } | |
| 146 | + | postExplicitCopy(consumePendingAction('cut'), selectedText()); | |
| 147 | + | }, true); | |
| 148 | + | ||
| 149 | + | if (document.execCommand) { | |
| 150 | + | var originalExecCommand = document.execCommand.bind(document); | |
| 151 | + | document.execCommand = function(commandId, showUI, value) { | |
| 152 | + | var command = String(commandId || '').toLowerCase(); | |
| 153 | + | var tracked = command === 'copy' || command === 'cut'; | |
| 154 | + | if (tracked) { | |
| 155 | + | pendingCommand = command; | |
| 156 | + | } | |
| 157 | + | try { | |
| 158 | + | return originalExecCommand(commandId, showUI, value); | |
| 159 | + | } finally { | |
| 160 | + | if (tracked && pendingCommand === command) { | |
| 161 | + | pendingCommand = ''; | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | }; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |
| 168 | + | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |
| 169 | + | navigator.clipboard.writeText = function(text) { | |
| 170 | + | var userActivated = hasUserActivation(); | |
| 171 | + | return Promise.resolve(originalWriteText(text)).then(function(result) { | |
| 172 | + | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | |
| 173 | + | if (userActivated) { | |
| 174 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |
| 175 | + | } | |
| 176 | + | return result; | |
| 177 | + | }); | |
| 178 | + | }; | |
| 179 | + | } | |
| 180 | + | })();` | |
| 181 | + | ||
| 62 | 182 | // accentDetectionScript is built at init from entity.AccentMap so the JS | |
| 63 | 183 | // filter stays in sync with the Go-side accent table. | |
| 64 | 184 | var accentDetectionScript string | |
| 65 | 185 | ||
| 186 | + | func buildExplicitCopyScript() string { | |
| 187 | + | return explicitCopyScript | |
| 188 | + | } | |
| 189 | + | ||
| 66 | 190 | func init() { | |
| 67 | 191 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |
| 68 | 192 | keys := make([]string, 0, len(entity.AccentMap)) | |
| @@ -229,7 +353,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 229 | 353 | "webrtc-compat-shim", | |
| 230 | 354 | ) | |
| 231 | 355 | ||
| 232 | - | // 2. Inject WebView ID for debugging (internal pages only) | |
| 356 | + | // 2. Inject explicit copy capture for all pages. | |
| 357 | + | addScript( | |
| 358 | + | webkit.NewUserScript( | |
| 359 | + | buildExplicitCopyScript(), | |
| 360 | + | webkit.UserContentInjectAllFramesValue, | |
| 361 | + | webkit.UserScriptInjectAtDocumentStartValue, | |
| 362 | + | nil, | |
| 363 | + | nil, | |
| 364 | + | ), | |
| 365 | + | "explicit-copy-capture", | |
| 366 | + | ) | |
| 367 | + | ||
| 368 | + | // 3. Inject WebView ID for debugging (internal pages only) | |
| 233 | 369 | if webviewID != 0 { | |
| 234 | 370 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 235 | 371 | addScript( | |
| @@ -247,7 +383,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 247 | 383 | log.Warn().Msg("webview ID is 0, skipping ID injection") | |
| 248 | 384 | } | |
| 249 | 385 | ||
| 250 | - | // 3. Inject dark mode handler for internal pages only | |
| 386 | + | // 4. Inject dark mode handler for internal pages only | |
| 251 | 387 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 252 | 388 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | |
| 253 | 389 | addScript( | |
| @@ -261,7 +397,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 261 | 397 | "internal-dark-mode-handler", | |
| 262 | 398 | ) | |
| 263 | 399 | ||
| 264 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 400 | + | // 5. Inject theme CSS for internal pages (dumb://* only) | |
| 265 | 401 | if ci.themeCSSVars != "" { | |
| 266 | 402 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | |
| 267 | 403 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | |
| @@ -278,7 +414,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 278 | 414 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | |
| 279 | 415 | } | |
| 280 | 416 | ||
| 281 | - | // 5. Inject find highlight CSS for all pages | |
| 417 | + | // 6. Inject find highlight CSS for all pages | |
| 282 | 418 | if ci.findCSS != "" { | |
| 283 | 419 | stylesheet := webkit.NewUserStyleSheet( | |
| 284 | 420 | ci.findCSS, | |
| @@ -295,7 +431,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 295 | 431 | } | |
| 296 | 432 | } | |
| 297 | 433 | ||
| 298 | - | // 6. Inject auto-copy selection script for all pages (if enabled) | |
| 434 | + | // 7. Inject auto-copy selection script for all pages (if enabled) | |
| 299 | 435 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |
| 300 | 436 | if autoCopyEnabled { | |
| 301 | 437 | addScript( | |
| @@ -311,7 +447,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 311 | 447 | log.Debug().Msg("auto-copy selection script injected") | |
| 312 | 448 | } | |
| 313 | 449 | ||
| 314 | - | // 7. Inject accent key detection for all pages and all frames (unconditional). | |
| 450 | + | // 8. Inject accent key detection for all pages and all frames (unconditional). | |
| 315 | 451 | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 316 | 452 | addScript( | |
| 317 | 453 | webkit.NewUserScript( | |
M internal/infrastructure/webkit/injector_accent_test.go +29 -1
| @@ -3,7 +3,7 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 6 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| @@ -30,3 +30,31 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||
| 30 | 30 | "accent detection script must register a focusin listener", | |
| 31 | 31 | ) | |
| 32 | 32 | } | |
| 33 | + | ||
| 34 | + | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |
| 35 | + | script := buildExplicitCopyScript() | |
| 36 | + | ||
| 37 | + | assert.Contains(t, script, "explicit_text_copy") | |
| 38 | + | assert.Contains(t, script, "document.addEventListener('copy'") | |
| 39 | + | assert.Contains(t, script, "document.addEventListener('cut'") | |
| 40 | + | assert.Contains(t, script, "document.execCommand") | |
| 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)") | |
| 45 | + | assert.Contains(t, script, "try {") | |
| 46 | + | assert.Contains(t, script, "finally {") | |
| 47 | + | assert.Contains(t, script, "pendingCommand === command") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |
| 51 | + | script := buildExplicitCopyScript() | |
| 52 | + | ||
| 53 | + | assert.Contains(t, script, "document.activeElement") | |
| 54 | + | assert.Contains(t, script, "selectionStart") | |
| 55 | + | assert.Contains(t, script, "selectionEnd") | |
| 56 | + | assert.Contains(t, script, "INPUT") | |
| 57 | + | assert.Contains(t, script, "TEXTAREA") | |
| 58 | + | assert.Contains(t, script, "type || '').toLowerCase()") | |
| 59 | + | assert.Contains(t, script, "case 'password':") | |
| 60 | + | } | |
M internal/infrastructure/webkit/memory_pressure.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 8 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | |
M internal/infrastructure/webkit/messaging.go +2 -2
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | - | "github.com/bnema/puregotk-webkit/javascriptcore" | |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 12 | + | "github.com/bnema/puregotk/v4/javascriptcore" | |
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // Message is the JS -> Go message envelope sent via postMessage. | |
M internal/infrastructure/webkit/pool.go +34 -2
| @@ -10,8 +10,8 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // ErrPoolClosed is returned when operations are attempted on a closed pool. | |
| @@ -65,9 +65,11 @@ type WebViewPool struct { | |||
| 65 | 65 | ||
| 66 | 66 | pool chan *WebView | |
| 67 | 67 | poolMu sync.Mutex | |
| 68 | + | ctxMenuMu sync.RWMutex | |
| 68 | 69 | injector *ContentInjector | |
| 69 | 70 | router *MessageRouter | |
| 70 | - | filterApplier FilterApplier // Optional content filter applier | |
| 71 | + | filterApplier FilterApplier // Optional content filter applier | |
| 72 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |
| 71 | 73 | ||
| 72 | 74 | // Background color for WebViews (eliminates white flash) | |
| 73 | 75 | bg bgColor | |
| @@ -145,6 +147,13 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||
| 145 | 147 | p.bg.set(r, g, b, a) | |
| 146 | 148 | } | |
| 147 | 149 | ||
| 150 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |
| 151 | + | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 152 | + | p.ctxMenuMu.Lock() | |
| 153 | + | p.ctxMenu = pipeline | |
| 154 | + | p.ctxMenuMu.Unlock() | |
| 155 | + | } | |
| 156 | + | ||
| 148 | 157 | // Acquire gets a WebView from the pool or creates a new one. | |
| 149 | 158 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 150 | 159 | log := logging.FromContext(ctx) | |
| @@ -171,6 +180,16 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 171 | 180 | if p.filterApplier != nil { | |
| 172 | 181 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 173 | 182 | } | |
| 183 | + | // Ensure context menu pipeline is set (may not have been available during prewarm) | |
| 184 | + | p.ctxMenuMu.RLock() | |
| 185 | + | ctxMenu := p.ctxMenu | |
| 186 | + | p.ctxMenuMu.RUnlock() | |
| 187 | + | if ctxMenu != nil { | |
| 188 | + | wv.contextMenu = ctxMenu | |
| 189 | + | if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) { | |
| 190 | + | wv.connectContextMenuSignal(ctxMenu) | |
| 191 | + | } | |
| 192 | + | } | |
| 174 | 193 | wv.ReconnectSignals() | |
| 175 | 194 | p.acquireCount.Add(1) | |
| 176 | 195 | p.acquirePoolHits.Add(1) | |
| @@ -214,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 214 | 233 | return wv, nil | |
| 215 | 234 | } | |
| 216 | 235 | ||
| 236 | + | func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool { | |
| 237 | + | return wv != nil && pipeline != nil && len(wv.signalIDs) > 0 | |
| 238 | + | } | |
| 239 | + | ||
| 217 | 240 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |
| 218 | 241 | log := logging.FromContext(ctx) | |
| 219 | 242 | ||
| @@ -237,6 +260,15 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 237 | 260 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 238 | 261 | } | |
| 239 | 262 | ||
| 263 | + | // Wire context menu if configured | |
| 264 | + | p.ctxMenuMu.RLock() | |
| 265 | + | ctxMenu := p.ctxMenu | |
| 266 | + | p.ctxMenuMu.RUnlock() | |
| 267 | + | if ctxMenu != nil { | |
| 268 | + | wv.contextMenu = ctxMenu | |
| 269 | + | wv.connectContextMenuSignal(ctxMenu) | |
| 270 | + | } | |
| 271 | + | ||
| 240 | 272 | return wv, nil | |
| 241 | 273 | } | |
| 242 | 274 | ||
A internal/infrastructure/webkit/pool_context_menu_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestNeedsExplicitContextMenuSignalOnAcquire(t *testing.T) { | |
| 6 | + | t.Run("true for prewarmed view that already has other signals", func(t *testing.T) { | |
| 7 | + | wv := &WebView{signalIDs: []uintptr{1, 2, 3}} | |
| 8 | + | if !needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) { | |
| 9 | + | t.Fatal("expected prewarmed webview to require explicit context-menu signal connection") | |
| 10 | + | } | |
| 11 | + | }) | |
| 12 | + | ||
| 13 | + | t.Run("false when pipeline missing", func(t *testing.T) { | |
| 14 | + | wv := &WebView{signalIDs: []uintptr{1}} | |
| 15 | + | if needsExplicitContextMenuSignalOnAcquire(wv, nil) { | |
| 16 | + | t.Fatal("expected missing pipeline to skip explicit context-menu signal connection") | |
| 17 | + | } | |
| 18 | + | }) | |
| 19 | + | ||
| 20 | + | t.Run("false for reused view with disconnected signals", func(t *testing.T) { | |
| 21 | + | wv := &WebView{} | |
| 22 | + | if needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) { | |
| 23 | + | t.Fatal("expected reused webview to rely on full signal reconnection") | |
| 24 | + | } | |
| 25 | + | }) | |
| 26 | + | } | |
A internal/infrastructure/webkit/renderer.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 4 | + | ||
| 5 | + | type Renderer = gtkmenu.Renderer | |
| 6 | + | ||
| 7 | + | func NewRenderer(dispatch func(func())) *Renderer { | |
| 8 | + | return gtkmenu.NewRenderer(dispatch) | |
| 9 | + | } | |
A internal/infrastructure/webkit/resolved_image_saver.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | resolvedImageDirPerm = 0o755 | |
| 15 | + | resolvedImageFilePerm = 0o644 | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk. | |
| 19 | + | type ResolvedImageSaver struct { | |
| 20 | + | preparer port.DownloadPreparer | |
| 21 | + | downloadDir string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewResolvedImageSaver creates a new ResolvedImageSaver helper. | |
| 25 | + | func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *ResolvedImageSaver { | |
| 26 | + | return &ResolvedImageSaver{ | |
| 27 | + | preparer: preparer, | |
| 28 | + | downloadDir: downloadDir, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // SaveResolvedImage resolves a destination path and writes the image bytes. | |
| 33 | + | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error { | |
| 34 | + | if s == nil || s.preparer == nil { | |
| 35 | + | return fmt.Errorf("resolved image saver: download preparer not available") | |
| 36 | + | } | |
| 37 | + | if len(image.Bytes) == 0 { | |
| 38 | + | return fmt.Errorf("resolved image saver: empty image data") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 42 | + | DownloadDir: s.downloadDir, | |
| 43 | + | // Leave SuggestedFilename empty so DownloadPreparer can fall back to | |
| 44 | + | // URI-derived naming when no explicit filename is available. | |
| 45 | + | Response: downloadResponse{ | |
| 46 | + | mimeType: image.MimeType, | |
| 47 | + | uri: menuContext.ImageURI, | |
| 48 | + | }, | |
| 49 | + | }) | |
| 50 | + | if output == nil || output.DestinationPath == "" { | |
| 51 | + | return fmt.Errorf("resolved image saver: destination path not prepared") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type downloadResponse struct { | |
| 64 | + | mimeType string | |
| 65 | + | suggestedFilename string | |
| 66 | + | uri string | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (r downloadResponse) GetMimeType() string { return r.mimeType } | |
| 70 | + | func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename } | |
| 71 | + | func (r downloadResponse) GetUri() string { return r.uri } | |
A internal/infrastructure/webkit/resolved_image_saver_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type fakeDownloadPreparer struct { | |
| 15 | + | calls int | |
| 16 | + | input port.DownloadPrepareInput | |
| 17 | + | output *port.DownloadPrepareOutput | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |
| 21 | + | f.calls++ | |
| 22 | + | f.input = input | |
| 23 | + | return f.output | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) { | |
| 27 | + | downloadDir := t.TempDir() | |
| 28 | + | destPath := filepath.Join(downloadDir, "image.png") | |
| 29 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | |
| 30 | + | saver := NewResolvedImageSaver(preparer, downloadDir) | |
| 31 | + | ||
| 32 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, downloadDir, preparer.input.DownloadDir) | |
| 35 | + | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | |
| 36 | + | require.Equal(t, "image/jpeg", preparer.input.Response.GetMimeType()) | |
| 37 | + | require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |
| 41 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | |
| 42 | + | saver := NewResolvedImageSaver(preparer, t.TempDir()) | |
| 43 | + | ||
| 44 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{}) | |
| 45 | + | require.Error(t, err) | |
| 46 | + | require.Contains(t, err.Error(), "destination path") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |
| 50 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | |
| 51 | + | saver := NewResolvedImageSaver(preparer, t.TempDir()) | |
| 52 | + | ||
| 53 | + | err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{}) | |
| 54 | + | require.Error(t, err) | |
| 55 | + | require.Contains(t, err.Error(), "empty image data") | |
| 56 | + | require.Zero(t, preparer.calls) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func readFile(t *testing.T, path string) []byte { | |
| 60 | + | t.Helper() | |
| 61 | + | data, err := os.ReadFile(path) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | return data | |
| 64 | + | } | |
M internal/infrastructure/webkit/scheme_handler.go +2 -2
| @@ -13,9 +13,9 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 15 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 16 | - | "github.com/bnema/puregotk-webkit/soup" | |
| 17 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 18 | 16 | "github.com/bnema/puregotk/v4/gio" | |
| 17 | + | "github.com/bnema/puregotk/v4/soup" | |
| 18 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 19 | 19 | "github.com/rs/zerolog" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
M internal/infrastructure/webkit/settings.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 10 | 10 | "github.com/rs/zerolog" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
M internal/infrastructure/webkit/webview.go +14 -19
| @@ -11,17 +11,18 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 16 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 18 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 19 | 19 | "github.com/bnema/puregotk/v4/gdk" | |
| 20 | 20 | "github.com/bnema/puregotk/v4/gio" | |
| 21 | 21 | "github.com/bnema/puregotk/v4/glib" | |
| 22 | 22 | "github.com/bnema/puregotk/v4/gobject" | |
| 23 | 23 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | |
| 24 | 24 | "github.com/bnema/puregotk/v4/gtk" | |
| 25 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 25 | 26 | "github.com/rs/zerolog" | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| @@ -29,6 +30,7 @@ import ( | |||
| 29 | 30 | var _ port.WebView = (*WebView)(nil) | |
| 30 | 31 | var _ port.DevToolsOpener = (*WebView)(nil) | |
| 31 | 32 | var _ port.Printer = (*WebView)(nil) | |
| 33 | + | var _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 32 | 34 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 33 | 35 | ||
| 34 | 36 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| @@ -185,6 +187,9 @@ type WebView struct { | |||
| 185 | 187 | ||
| 186 | 188 | backForwardList *webkit.BackForwardList | |
| 187 | 189 | backForwardListSignalID uintptr | |
| 190 | + | ||
| 191 | + | // contextMenu holds the optional context menu pipeline for reconnection. | |
| 192 | + | contextMenu *contextMenuPipeline | |
| 188 | 193 | } | |
| 189 | 194 | ||
| 190 | 195 | type runJSErrorStat struct { | |
| @@ -429,6 +434,7 @@ func (wv *WebView) connectSignals() { | |||
| 429 | 434 | wv.connectBackForwardListChangedSignal() | |
| 430 | 435 | wv.connectWebProcessTerminatedSignal() | |
| 431 | 436 | wv.connectPermissionRequestSignal() | |
| 437 | + | wv.connectContextMenuSignal(wv.contextMenu) | |
| 432 | 438 | } | |
| 433 | 439 | ||
| 434 | 440 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -820,22 +826,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool { | |||
| 820 | 826 | return false | |
| 821 | 827 | } | |
| 822 | 828 | ||
| 823 | - | mimeType := strings.ToLower(response.GetMimeType()) | |
| 824 | - | if strings.HasPrefix(mimeType, "application/pdf") { | |
| 825 | - | return true | |
| 826 | - | } | |
| 827 | - | ||
| 828 | - | uri := response.GetUri() | |
| 829 | - | if uri == "" { | |
| 830 | - | return false | |
| 831 | - | } | |
| 832 | - | ||
| 833 | - | parsed, err := url.Parse(uri) | |
| 834 | - | if err != nil { | |
| 835 | - | return strings.Contains(strings.ToLower(uri), ".pdf") | |
| 836 | - | } | |
| 837 | - | ||
| 838 | - | return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf") | |
| 829 | + | return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType()) | |
| 839 | 830 | } | |
| 840 | 831 | ||
| 841 | 832 | func (wv *WebView) connectEnterFullscreenSignal() { | |
| @@ -1594,15 +1585,19 @@ func (wv *WebView) Show() { | |||
| 1594 | 1585 | wv.inner.SetVisible(true) | |
| 1595 | 1586 | } | |
| 1596 | 1587 | ||
| 1588 | + | // PrimePopupNavigation is a no-op for WebKit, which already has a native popup | |
| 1589 | + | // browser at the time the coordinator receives popup lifecycle callbacks. | |
| 1590 | + | func (*WebView) PrimePopupNavigation(string) {} | |
| 1591 | + | ||
| 1597 | 1592 | // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display. | |
| 1598 | - | // It implements port.PopupCapable. | |
| 1593 | + | // It implements port.PopupLifecycleCapable. | |
| 1599 | 1594 | func (wv *WebView) SetOnReadyToShow(fn func()) { | |
| 1600 | 1595 | wv.OnReadyToShow = fn | |
| 1601 | 1596 | } | |
| 1602 | 1597 | ||
| 1603 | 1598 | // SetOnClose composes fn with any existing OnClose handler so multiple callers | |
| 1604 | 1599 | // can each register a close callback without overwriting one another. | |
| 1605 | - | // It implements port.PopupCapable. | |
| 1600 | + | // It implements port.PopupLifecycleCapable. | |
| 1606 | 1601 | func (wv *WebView) SetOnClose(fn func()) { | |
| 1607 | 1602 | existing := wv.OnClose | |
| 1608 | 1603 | if existing == nil { | |
M internal/infrastructure/webkit/webview_termination_test.go +1 -1
| @@ -3,7 +3,7 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |
| 6 | + | webkitlib "github.com/bnema/puregotk/v4/webkit" | |
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/infrastructure/xdg/adapter.go +23 -3
| @@ -9,11 +9,14 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | |
| 12 | - | type Adapter struct{} | |
| 12 | + | type Adapter struct { | |
| 13 | + | isDev bool | |
| 14 | + | runtimeDir string | |
| 15 | + | } | |
| 13 | 16 | ||
| 14 | 17 | // New creates a new XDG paths adapter. | |
| 15 | - | func New() *Adapter { | |
| 16 | - | return &Adapter{} | |
| 18 | + | func New(isDev bool, runtimeDir string) *Adapter { | |
| 19 | + | return &Adapter{isDev: isDev, runtimeDir: runtimeDir} | |
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | func (a *Adapter) ConfigDir() (string, error) { | |
| @@ -28,6 +31,23 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 28 | 31 | return config.GetStateDir() | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 34 | + | func (a *Adapter) RuntimeDir() (string, error) { | |
| 35 | + | if a == nil || !a.isDev { | |
| 36 | + | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 37 | + | return dir, nil | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | if a != nil && a.runtimeDir != "" { | |
| 41 | + | return a.runtimeDir, nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | stateDir, err := config.GetStateDir() | |
| 45 | + | if err != nil { | |
| 46 | + | return "", err | |
| 47 | + | } | |
| 48 | + | return filepath.Join(stateDir, "runtime"), nil | |
| 49 | + | } | |
| 50 | + | ||
| 31 | 51 | func (a *Adapter) CacheDir() (string, error) { | |
| 32 | 52 | dirs, err := config.GetXDGDirs() | |
| 33 | 53 | if err != nil { | |
M internal/infrastructure/xdg/adapter_test.go +37 -24
| @@ -10,20 +10,10 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestAdapter_DownloadDir(t *testing.T) { | |
| 13 | - | adapter := New() | |
| 13 | + | adapter := New(false, "") | |
| 14 | 14 | ||
| 15 | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | |
| 16 | - | // Save and restore original env. | |
| 17 | - | original := os.Getenv("XDG_DOWNLOAD_DIR") | |
| 18 | - | defer func() { | |
| 19 | - | if original == "" { | |
| 20 | - | os.Unsetenv("XDG_DOWNLOAD_DIR") | |
| 21 | - | } else { | |
| 22 | - | os.Setenv("XDG_DOWNLOAD_DIR", original) | |
| 23 | - | } | |
| 24 | - | }() | |
| 25 | - | ||
| 26 | - | os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | |
| 16 | + | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | |
| 27 | 17 | ||
| 28 | 18 | dir, err := adapter.DownloadDir() | |
| 29 | 19 | ||
| @@ -32,17 +22,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 32 | 22 | }) | |
| 33 | 23 | ||
| 34 | 24 | t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) { | |
| 35 | - | // Save and restore original env. | |
| 36 | - | original := os.Getenv("XDG_DOWNLOAD_DIR") | |
| 37 | - | defer func() { | |
| 38 | - | if original == "" { | |
| 39 | - | os.Unsetenv("XDG_DOWNLOAD_DIR") | |
| 40 | - | } else { | |
| 41 | - | os.Setenv("XDG_DOWNLOAD_DIR", original) | |
| 42 | - | } | |
| 43 | - | }() | |
| 44 | - | ||
| 45 | - | os.Unsetenv("XDG_DOWNLOAD_DIR") | |
| 25 | + | t.Setenv("XDG_DOWNLOAD_DIR", "") | |
| 46 | 26 | ||
| 47 | 27 | dir, err := adapter.DownloadDir() | |
| 48 | 28 | ||
| @@ -56,8 +36,41 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 56 | 36 | }) | |
| 57 | 37 | } | |
| 58 | 38 | ||
| 39 | + | func TestAdapter_RuntimeDir(t *testing.T) { | |
| 40 | + | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | |
| 41 | + | adapter := New(false, "") | |
| 42 | + | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | |
| 43 | + | ||
| 44 | + | dir, err := adapter.RuntimeDir() | |
| 45 | + | ||
| 46 | + | require.NoError(t, err) | |
| 47 | + | assert.Equal(t, "/custom/runtime", dir) | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | |
| 51 | + | adapter := New(false, "/tmp/dumber-state/dumber/runtime") | |
| 52 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 53 | + | ||
| 54 | + | dir, err := adapter.RuntimeDir() | |
| 55 | + | ||
| 56 | + | require.NoError(t, err) | |
| 57 | + | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | |
| 58 | + | }) | |
| 59 | + | ||
| 60 | + | t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | |
| 61 | + | wd := t.TempDir() | |
| 62 | + | adapter := New(true, filepath.Join(wd, ".dev", "dumber", "runtime")) | |
| 63 | + | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | |
| 64 | + | ||
| 65 | + | dir, err := adapter.RuntimeDir() | |
| 66 | + | ||
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir) | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | ||
| 59 | 72 | func TestNew(t *testing.T) { | |
| 60 | - | adapter := New() | |
| 73 | + | adapter := New(false, "") | |
| 61 | 74 | ||
| 62 | 75 | assert.NotNil(t, adapter) | |
| 63 | 76 | } | |
M internal/ui/app.go +956 -460
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| @@ -36,14 +37,17 @@ import ( | |||
| 36 | 37 | ||
| 37 | 38 | const ( | |
| 38 | 39 | // AppID is the application identifier for GTK. | |
| 39 | - | AppID = "com.github.bnema.dumber" | |
| 40 | + | AppID = "com.github.bnema.dumber" | |
| 41 | + | appTitle = "Dumber" | |
| 40 | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 41 | - | crashReportToastDurationMs = 5000 | |
| 42 | - | floatingPaneFallbackWidth = 1200 | |
| 43 | - | floatingPaneFallbackHeight = 800 | |
| 44 | - | floatingPaneIDPrefix = "floating-pane:" | |
| 45 | - | floatingSessionIDDefault = "default" | |
| 46 | - | floatingPaneVisibleClass = "floating-pane-visible" | |
| 43 | + | crashReportToastDurationMs = 5000 | |
| 44 | + | downloadToastTerminalDuration = 2500 | |
| 45 | + | downloadProgressRoundBias = 0.5 | |
| 46 | + | floatingPaneFallbackWidth = 1200 | |
| 47 | + | floatingPaneFallbackHeight = 800 | |
| 48 | + | floatingPaneIDPrefix = "floating-pane:" | |
| 49 | + | floatingSessionIDDefault = "default" | |
| 50 | + | floatingPaneVisibleClass = "floating-pane-visible" | |
| 47 | 51 | ) | |
| 48 | 52 | ||
| 49 | 53 | func gtkApplicationFlags() gio.ApplicationFlags { | |
| @@ -65,6 +69,12 @@ type App struct { | |||
| 65 | 69 | gtkApp *gtk.Application | |
| 66 | 70 | mainWindow *window.MainWindow | |
| 67 | 71 | ||
| 72 | + | browserWindows map[string]*browserWindow | |
| 73 | + | lastFocusedWindowID string | |
| 74 | + | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| 75 | + | tabCreationWindow *browserWindow | |
| 76 | + | dispatchOnMainThread func(func()) | |
| 77 | + | ||
| 68 | 78 | // State | |
| 69 | 79 | tabs *entity.TabList | |
| 70 | 80 | tabsUC *usecase.ManageTabsUseCase | |
| @@ -79,6 +89,7 @@ type App struct { | |||
| 79 | 89 | // Pane management (used by coordinators) | |
| 80 | 90 | panesUC *usecase.ManagePanesUseCase | |
| 81 | 91 | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 92 | + | windowForTab map[entity.TabID]*browserWindow | |
| 82 | 93 | widgetFactory layout.WidgetFactory | |
| 83 | 94 | stackedPaneMgr *component.StackedPaneManager | |
| 84 | 95 | ||
| @@ -86,9 +97,8 @@ type App struct { | |||
| 86 | 97 | keyboardHandler *input.KeyboardHandler | |
| 87 | 98 | globalShortcutHandler *input.GlobalShortcutHandler | |
| 88 | 99 | ||
| 89 | - | // Focus and border management | |
| 90 | - | focusMgr *focus.Manager | |
| 91 | - | borderMgr *focus.BorderManager | |
| 100 | + | // Focus tracking stays app-global. | |
| 101 | + | focusMgr *focus.Manager | |
| 92 | 102 | ||
| 93 | 103 | resizeModeBorderTarget layout.Widget | |
| 94 | 104 | ||
| @@ -106,18 +116,6 @@ type App struct { | |||
| 106 | 116 | // Web content (managed by content.Coordinator) | |
| 107 | 117 | faviconAdapter *adapter.FaviconAdapter | |
| 108 | 118 | ||
| 109 | - | // App-level toaster for system notifications (filter status, etc.) | |
| 110 | - | appToaster *component.Toaster | |
| 111 | - | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |
| 112 | - | modeToaster *component.Toaster | |
| 113 | - | // Top-right WebRTC permission activity indicator | |
| 114 | - | webrtcIndicator *component.WebRTCPermissionIndicator | |
| 115 | - | ||
| 116 | - | // Session management | |
| 117 | - | sessionManager *component.SessionManager | |
| 118 | - | tabPicker *component.TabPicker | |
| 119 | - | tabPickerWidget layout.Widget | |
| 120 | - | tabPickerPaneID entity.PaneID | |
| 121 | 119 | snapshotService port.SnapshotService | |
| 122 | 120 | ||
| 123 | 121 | // Update management | |
| @@ -126,13 +124,13 @@ type App struct { | |||
| 126 | 124 | // ID generator for tabs/panes | |
| 127 | 125 | idCounter uint64 | |
| 128 | 126 | idMu sync.Mutex | |
| 127 | + | windowIDCounter uint64 | |
| 128 | + | windowIDMu sync.Mutex | |
| 129 | 129 | firstWebViewShownOnce sync.Once | |
| 130 | 130 | ||
| 131 | 131 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 132 | 132 | ||
| 133 | 133 | // Accent picker for dead keys support | |
| 134 | - | accentPicker *component.AccentPicker | |
| 135 | - | insertAccentUC *usecase.InsertAccentUseCase | |
| 136 | 134 | accentFocusProvider port.FocusedInputProvider | |
| 137 | 135 | ||
| 138 | 136 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| @@ -140,7 +138,9 @@ type App struct { | |||
| 140 | 138 | deferredInitFn func() | |
| 141 | 139 | ||
| 142 | 140 | // lifecycle | |
| 143 | - | cancel context.CancelCauseFunc | |
| 141 | + | cancel context.CancelCauseFunc | |
| 142 | + | browserLaunchRelayOnce sync.Once | |
| 143 | + | browserLaunchRelayCloser io.Closer | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | type floatingWorkspaceSession struct { | |
| @@ -172,34 +172,51 @@ func New(deps *Dependencies) (*App, error) { | |||
| 172 | 172 | ctx, cancel := context.WithCancelCause(deps.Ctx) | |
| 173 | 173 | ||
| 174 | 174 | app := &App{ | |
| 175 | - | deps: deps, | |
| 176 | - | tabs: entity.NewTabList(), | |
| 177 | - | tabsUC: deps.TabsUC, | |
| 178 | - | panesUC: deps.PanesUC, | |
| 179 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 180 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 181 | - | engine: deps.Engine, | |
| 182 | - | cancel: cancel, | |
| 183 | - | } | |
| 184 | - | // Register message handlers through the engine. | |
| 185 | - | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 186 | - | HistoryUC: deps.HistoryUC, | |
| 187 | - | FavoritesUC: deps.FavoritesUC, | |
| 188 | - | Clipboard: deps.Clipboard, | |
| 189 | - | AutoCopyConfig: &autoCopyConfigFn{fn: func() bool { | |
| 175 | + | deps: deps, | |
| 176 | + | tabs: entity.NewTabList(), | |
| 177 | + | tabsUC: deps.TabsUC, | |
| 178 | + | panesUC: deps.PanesUC, | |
| 179 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 180 | + | windowForTab: make(map[entity.TabID]*browserWindow), | |
| 181 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 182 | + | browserWindows: make(map[string]*browserWindow), | |
| 183 | + | dispatchOnMainThread: func(fn func()) { fn() }, | |
| 184 | + | engine: deps.Engine, | |
| 185 | + | cancel: cancel, | |
| 186 | + | } | |
| 187 | + | var autoCopyConfig port.AutoCopyConfig | |
| 188 | + | var clipboardOrchestrator port.ClipboardTextOrchestrator | |
| 189 | + | if deps.Clipboard != nil { | |
| 190 | + | autoCopyConfig = &autoCopyConfigFn{fn: func() bool { | |
| 190 | 191 | if deps.Config == nil { | |
| 191 | 192 | return false | |
| 192 | 193 | } | |
| 193 | 194 | return deps.Config.Clipboard.AutoCopyOnSelection | |
| 194 | - | }}, | |
| 195 | + | }} | |
| 196 | + | clipboardOrchestrator = usecase.NewClipboardTextOrchestrator(deps.Clipboard, autoCopyConfig, func(textLen int) { | |
| 197 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 198 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| 199 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 200 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 201 | + | ) | |
| 202 | + | return false | |
| 203 | + | }) | |
| 204 | + | glib.IdleAdd(&cb, 0) | |
| 205 | + | }) | |
| 206 | + | } | |
| 207 | + | // Register message handlers through the engine. | |
| 208 | + | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 209 | + | HistoryUC: deps.HistoryUC, | |
| 210 | + | FavoritesUC: deps.FavoritesUC, | |
| 211 | + | Clipboard: deps.Clipboard, | |
| 212 | + | AutoCopyConfig: autoCopyConfig, | |
| 213 | + | ClipboardTextOrchestrator: clipboardOrchestrator, | |
| 195 | 214 | OnClipboardCopied: func(textLen int) { | |
| 196 | 215 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 197 | - | if app.appToaster != nil { | |
| 198 | - | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | |
| 199 | - | component.WithDuration(component.ToastBriefDurationMs), | |
| 200 | - | component.WithPosition(component.ToastPositionBottomRight), | |
| 201 | - | ) | |
| 202 | - | } | |
| 216 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| 217 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 218 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 219 | + | ) | |
| 203 | 220 | return false | |
| 204 | 221 | }) | |
| 205 | 222 | glib.IdleAdd(&cb, 0) | |
| @@ -244,6 +261,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 244 | 261 | } | |
| 245 | 262 | defer a.gtkApp.Unref() | |
| 246 | 263 | logging.Trace().Mark("gtk_app_created") | |
| 264 | + | a.dispatchOnMainThread = a.runOnMainThread | |
| 247 | 265 | ||
| 248 | 266 | // Connect activate signal | |
| 249 | 267 | activateCb := func(_ gio.Application) { | |
| @@ -272,18 +290,16 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 272 | 290 | // Configure pool background color early (prevents white flash), but avoid | |
| 273 | 291 | // synchronous prewarming that delays the first navigation on cold start. | |
| 274 | 292 | a.setupPoolBackgroundColor(ctx) | |
| 293 | + | a.initLayoutInfrastructure() | |
| 275 | 294 | ||
| 276 | - | if err := a.createMainWindow(ctx); err != nil { | |
| 295 | + | if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | |
| 277 | 296 | log.Error().Err(err).Msg("failed to create main window") | |
| 278 | 297 | return | |
| 279 | 298 | } | |
| 280 | 299 | logging.Trace().Mark("window_created") | |
| 281 | 300 | ||
| 282 | - | a.initLayoutInfrastructure() | |
| 283 | - | a.initAppToasterOverlay() | |
| 284 | 301 | a.installCrashReportNotifier(ctx) | |
| 285 | - | a.initFocusAndBorderOverlay() | |
| 286 | - | a.initAccentPicker(ctx) | |
| 302 | + | a.initFocusManager() | |
| 287 | 303 | a.registerAccentHandlers(ctx) | |
| 288 | 304 | a.initDownloadHandler(ctx) | |
| 289 | 305 | ||
| @@ -293,8 +309,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 293 | 309 | a.initKeyboardHandler(ctx) | |
| 294 | 310 | a.initOmniboxConfig(ctx) | |
| 295 | 311 | a.initFindBarConfig(ctx) | |
| 296 | - | a.initSessionManager(ctx) | |
| 297 | - | a.initTabPicker(ctx) | |
| 312 | + | a.wireSessionManagerShortcut() | |
| 298 | 313 | a.initSnapshotService(ctx) | |
| 299 | 314 | a.initUpdateCoordinator(ctx) | |
| 300 | 315 | a.createInitialTab(ctx) | |
| @@ -380,19 +395,38 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||
| 380 | 395 | pool.PrewarmAsync(ctx, 0) | |
| 381 | 396 | } | |
| 382 | 397 | ||
| 383 | - | func (a *App) createMainWindow(ctx context.Context) error { | |
| 398 | + | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { | |
| 399 | + | if a.browserWindowFactory != nil { | |
| 400 | + | created, err := a.browserWindowFactory(ctx, initialURL) | |
| 401 | + | if err != nil { | |
| 402 | + | return nil, err | |
| 403 | + | } | |
| 404 | + | a.wireBrowserWindowActivationTracking(created) | |
| 405 | + | created.initChrome(ctx, a) | |
| 406 | + | return created, nil | |
| 407 | + | } | |
| 408 | + | ||
| 384 | 409 | log := logging.FromContext(ctx) | |
| 385 | 410 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | |
| 386 | 411 | if err != nil { | |
| 387 | - | return err | |
| 412 | + | return nil, err | |
| 413 | + | } | |
| 414 | + | browserWindow := &browserWindow{ | |
| 415 | + | id: a.generateWindowID(), | |
| 416 | + | initialURL: initialURL, | |
| 417 | + | mainWindow: mainWindow, | |
| 418 | + | } | |
| 419 | + | a.wireBrowserWindowActivationTracking(browserWindow) | |
| 420 | + | if a.mainWindow == nil { | |
| 421 | + | a.mainWindow = mainWindow | |
| 388 | 422 | } | |
| 389 | - | a.mainWindow = mainWindow | |
| 390 | 423 | ||
| 391 | 424 | closeRequestCb := func(_ gtk.Window) bool { | |
| 392 | - | log.Info().Msg("main window close requested") | |
| 425 | + | log.Info().Msg("browser window close requested") | |
| 426 | + | a.removeBrowserWindow(browserWindow.id) | |
| 393 | 427 | return false | |
| 394 | 428 | } | |
| 395 | - | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 429 | + | mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 396 | 430 | ||
| 397 | 431 | // Create permission popup and dialog presenter | |
| 398 | 432 | if a.deps != nil && a.deps.PermissionUC != nil { | |
| @@ -404,10 +438,9 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 404 | 438 | if permPopup != nil { | |
| 405 | 439 | // Add popup to the main window's content overlay | |
| 406 | 440 | if w := permPopup.Widget(); w != nil { | |
| 407 | - | a.mainWindow.AddOverlay(w) | |
| 441 | + | mainWindow.AddOverlay(w) | |
| 408 | 442 | } | |
| 409 | - | permDialog := dialog.NewPermissionDialog(permPopup) | |
| 410 | - | a.deps.PermissionUC.SetDialogPresenter(permDialog) | |
| 443 | + | browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup) | |
| 411 | 444 | } | |
| 412 | 445 | } | |
| 413 | 446 | ||
| @@ -415,155 +448,417 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 415 | 448 | indicator := component.NewWebRTCPermissionIndicator() | |
| 416 | 449 | if indicator != nil { | |
| 417 | 450 | if w := indicator.Widget(); w != nil { | |
| 418 | - | a.mainWindow.AddOverlay(w) | |
| 419 | - | a.webrtcIndicator = indicator | |
| 451 | + | mainWindow.AddOverlay(w) | |
| 420 | 452 | } | |
| 453 | + | browserWindow.webrtcIndicator = indicator | |
| 454 | + | } | |
| 455 | + | a.wireBrowserWindowPermissionIndicator(browserWindow) | |
| 456 | + | if a.kbDispatcher != nil { | |
| 457 | + | a.initBrowserWindowInput(ctx, browserWindow) | |
| 421 | 458 | } | |
| 459 | + | if a.tabCoord != nil { | |
| 460 | + | a.wireBrowserWindowTabBar(ctx, browserWindow) | |
| 461 | + | } | |
| 462 | + | browserWindow.initChrome(ctx, a) | |
| 422 | 463 | ||
| 423 | 464 | // Apply GTK CSS styling from theme manager. | |
| 424 | 465 | if a.deps == nil || a.deps.Theme == nil { | |
| 425 | - | return nil | |
| 466 | + | return browserWindow, nil | |
| 426 | 467 | } | |
| 427 | - | if display := a.mainWindow.Window().GetDisplay(); display != nil { | |
| 468 | + | if display := mainWindow.Window().GetDisplay(); display != nil { | |
| 428 | 469 | a.deps.Theme.ApplyToDisplay(ctx, display) | |
| 429 | 470 | } | |
| 471 | + | return browserWindow, nil | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func (a *App) ensureWindowForTabMap() { | |
| 475 | + | if a.windowForTab == nil { | |
| 476 | + | a.windowForTab = make(map[entity.TabID]*browserWindow) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browserWindow { | |
| 481 | + | if mainWindow == nil { | |
| 482 | + | return nil | |
| 483 | + | } | |
| 484 | + | for _, bw := range a.browserWindows { | |
| 485 | + | if bw != nil && bw.mainWindow == mainWindow { | |
| 486 | + | return bw | |
| 487 | + | } | |
| 488 | + | } | |
| 430 | 489 | return nil | |
| 431 | 490 | } | |
| 432 | 491 | ||
| 433 | - | func (a *App) initLayoutInfrastructure() { | |
| 434 | - | // Initialize widget factory for pane layout. | |
| 435 | - | a.widgetFactory = layout.NewGtkWidgetFactory() | |
| 492 | + | func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow { | |
| 493 | + | if a.windowForTab != nil { | |
| 494 | + | if bw := a.windowForTab[tabID]; bw != nil { | |
| 495 | + | return bw | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | if a.tabCreationWindow != nil { | |
| 499 | + | return a.tabCreationWindow | |
| 500 | + | } | |
| 501 | + | return a.browserWindowForMainWindow(a.mainWindow) | |
| 502 | + | } | |
| 436 | 503 | ||
| 437 | - | // Initialize stacked pane manager for incremental widget operations. | |
| 438 | - | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |
| 504 | + | func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow { | |
| 505 | + | if paneID == "" || a.tabs == nil { | |
| 506 | + | return nil | |
| 507 | + | } | |
| 508 | + | for _, tab := range a.tabs.Tabs { | |
| 509 | + | if tab == nil || tab.Workspace == nil { | |
| 510 | + | continue | |
| 511 | + | } | |
| 512 | + | if tab.Workspace.FindPane(paneID) != nil { | |
| 513 | + | return a.browserWindowForTab(tab.ID) | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | return nil | |
| 439 | 517 | } | |
| 440 | 518 | ||
| 441 | - | func (a *App) initAppToasterOverlay() { | |
| 442 | - | if a.mainWindow == nil { | |
| 519 | + | func (a *App) lastFocusedBrowserWindow() *browserWindow { | |
| 520 | + | if a.lastFocusedWindowID == "" || a.browserWindows == nil { | |
| 521 | + | return nil | |
| 522 | + | } | |
| 523 | + | return a.browserWindows[a.lastFocusedWindowID] | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func (a *App) showToastOnBrowserWindow( | |
| 527 | + | ctx context.Context, | |
| 528 | + | bw *browserWindow, | |
| 529 | + | message string, | |
| 530 | + | level component.ToastLevel, | |
| 531 | + | opts ...component.ToastOption, | |
| 532 | + | ) { | |
| 533 | + | if bw == nil || bw.appToaster == nil { | |
| 443 | 534 | return | |
| 444 | 535 | } | |
| 536 | + | bw.appToaster.Show(ctx, message, level, opts...) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func (a *App) showToastOnLastFocusedBrowserWindow( | |
| 540 | + | ctx context.Context, | |
| 541 | + | message string, | |
| 542 | + | level component.ToastLevel, | |
| 543 | + | opts ...component.ToastOption, | |
| 544 | + | ) { | |
| 545 | + | a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...) | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow { | |
| 549 | + | if paneID != "" { | |
| 550 | + | if bw := a.browserWindowForPane(paneID); bw != nil { | |
| 551 | + | return bw | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | if tabID != "" { | |
| 555 | + | if bw := a.browserWindowForTab(tabID); bw != nil { | |
| 556 | + | return bw | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | return a.lastFocusedBrowserWindow() | |
| 560 | + | } | |
| 445 | 561 | ||
| 446 | - | // Create app-level toaster for system notifications. | |
| 447 | - | a.appToaster = component.NewToaster(a.widgetFactory) | |
| 448 | - | toasterWidget := a.appToaster.Widget() | |
| 449 | - | if toasterWidget == nil { | |
| 562 | + | func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) { | |
| 563 | + | a.updateWindowTitle(title, a.browserWindowForPane(paneID)) | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) { | |
| 567 | + | bw := a.browserWindowForPane(paneID) | |
| 568 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |
| 450 | 569 | return | |
| 451 | 570 | } | |
| 452 | - | gtkWidget := toasterWidget.GtkWidget() | |
| 453 | - | if gtkWidget == nil { | |
| 571 | + | if entering { | |
| 572 | + | bw.mainWindow.TabBar().SetVisible(false) | |
| 454 | 573 | return | |
| 455 | 574 | } | |
| 456 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 575 | + | a.updateBrowserWindowTabBarVisibility(bw) | |
| 576 | + | } | |
| 457 | 577 | ||
| 458 | - | // Create mode indicator toaster for modal mode notifications. | |
| 459 | - | a.modeToaster = component.NewToaster(a.widgetFactory) | |
| 460 | - | modeToasterWidget := a.modeToaster.Widget() | |
| 461 | - | if modeToasterWidget == nil { | |
| 578 | + | func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) { | |
| 579 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |
| 462 | 580 | return | |
| 463 | 581 | } | |
| 464 | - | modeGtkWidget := modeToasterWidget.GtkWidget() | |
| 465 | - | if modeGtkWidget == nil { | |
| 582 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab { | |
| 583 | + | bw.mainWindow.TabBar().SetVisible(true) | |
| 466 | 584 | return | |
| 467 | 585 | } | |
| 468 | - | a.mainWindow.AddOverlay(modeGtkWidget) | |
| 586 | + | shouldShow := bw.mainWindow.TabBar().Count() > 1 | |
| 587 | + | bw.mainWindow.TabBar().SetVisible(shouldShow) | |
| 469 | 588 | } | |
| 470 | 589 | ||
| 471 | - | func (a *App) installCrashReportNotifier(ctx context.Context) { | |
| 472 | - | if a.deps == nil { | |
| 590 | + | func (a *App) activateBrowserWindow(bw *browserWindow) { | |
| 591 | + | if bw == nil { | |
| 473 | 592 | return | |
| 474 | 593 | } | |
| 475 | - | ||
| 476 | - | a.deps.OnCrashReportsDetected = func(paths []string) { | |
| 477 | - | a.showCrashReportToast(ctx, paths) | |
| 594 | + | a.lastFocusedWindowID = bw.id | |
| 595 | + | if bw.mainWindow != nil { | |
| 596 | + | a.mainWindow = bw.mainWindow | |
| 597 | + | } | |
| 598 | + | a.keyboardHandler = bw.keyboardHandler | |
| 599 | + | a.globalShortcutHandler = bw.globalShortcutHandler | |
| 600 | + | if a.tabCoord != nil && bw.mainWindow != nil { | |
| 601 | + | a.tabCoord.SetMainWindow(bw.mainWindow) | |
| 602 | + | } | |
| 603 | + | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | |
| 604 | + | a.tabs.SetActive(bw.activeTabID) | |
| 478 | 605 | } | |
| 606 | + | a.updateWindowTitleFromActivePane(bw.activeTabID) | |
| 479 | 607 | } | |
| 480 | 608 | ||
| 481 | - | func (a *App) initFocusAndBorderOverlay() { | |
| 482 | - | // Initialize focus manager for geometric navigation. | |
| 483 | - | a.focusMgr = focus.NewManager(a.panesUC) | |
| 484 | - | ||
| 485 | - | // Initialize border manager for mode indicators. | |
| 486 | - | a.borderMgr = focus.NewBorderManager(a.widgetFactory) | |
| 487 | - | ||
| 488 | - | // Attach border overlay to main window (visible for all tabs). | |
| 489 | - | if a.borderMgr == nil || a.mainWindow == nil { | |
| 609 | + | func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) { | |
| 610 | + | if !active || bw == nil { | |
| 490 | 611 | return | |
| 491 | 612 | } | |
| 492 | - | borderWidget := a.borderMgr.Widget() | |
| 493 | - | if borderWidget == nil { | |
| 613 | + | a.activateBrowserWindow(bw) | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) { | |
| 617 | + | if bw == nil || bw.mainWindow == nil { | |
| 494 | 618 | return | |
| 495 | 619 | } | |
| 496 | - | gtkWidget := borderWidget.GtkWidget() | |
| 497 | - | if gtkWidget == nil { | |
| 620 | + | bw.mainWindow.ConnectActiveNotify(func(active bool) { | |
| 621 | + | a.handleBrowserWindowActivationChanged(bw, active) | |
| 622 | + | }) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | |
| 626 | + | if bw == nil { | |
| 498 | 627 | return | |
| 499 | 628 | } | |
| 500 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 629 | + | a.ensureWindowForTabMap() | |
| 630 | + | a.windowForTab[tabID] = bw | |
| 631 | + | if bw.activeTabID == "" { | |
| 632 | + | bw.activeTabID = tabID | |
| 633 | + | } | |
| 501 | 634 | } | |
| 502 | 635 | ||
| 503 | - | func (a *App) initAccentPicker(ctx context.Context) { | |
| 504 | - | log := logging.FromContext(ctx) | |
| 636 | + | func (a *App) tabBarForBrowserWindow(bw *browserWindow) *component.TabBar { | |
| 637 | + | if bw != nil && bw.mainWindow != nil { | |
| 638 | + | return bw.mainWindow.TabBar() | |
| 639 | + | } | |
| 640 | + | if a.mainWindow != nil { | |
| 641 | + | return a.mainWindow.TabBar() | |
| 642 | + | } | |
| 643 | + | return nil | |
| 644 | + | } | |
| 505 | 645 | ||
| 506 | - | if a.widgetFactory == nil || a.mainWindow == nil { | |
| 507 | - | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | |
| 646 | + | func (a *App) startBrowserLaunchRelayListener(ctx context.Context) { | |
| 647 | + | if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil { | |
| 508 | 648 | return | |
| 509 | 649 | } | |
| 650 | + | a.browserLaunchRelayOnce.Do(func() { | |
| 651 | + | closer, err := a.deps.BrowserLaunchRelay.Listen(ctx, a) | |
| 652 | + | if err != nil { | |
| 653 | + | logging.FromContext(ctx).Warn().Err(err).Msg("failed to start browser launch relay listener") | |
| 654 | + | return | |
| 655 | + | } | |
| 656 | + | a.browserLaunchRelayCloser = closer | |
| 657 | + | }) | |
| 658 | + | } | |
| 510 | 659 | ||
| 511 | - | // Create accent picker component | |
| 512 | - | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | |
| 513 | - | if a.accentPicker == nil { | |
| 514 | - | log.Warn().Msg("failed to create accent picker") | |
| 660 | + | func (a *App) closeBrowserLaunchRelayListener() { | |
| 661 | + | if a.browserLaunchRelayCloser == nil { | |
| 515 | 662 | return | |
| 516 | 663 | } | |
| 664 | + | _ = a.browserLaunchRelayCloser.Close() | |
| 665 | + | a.browserLaunchRelayCloser = nil | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | func (a *App) openFreshWindow(ctx context.Context, url string) error { | |
| 669 | + | created, err := a.createBrowserWindow(ctx, url) | |
| 670 | + | if err != nil { | |
| 671 | + | return err | |
| 672 | + | } | |
| 673 | + | a.registerBrowserWindow(created) | |
| 674 | + | ||
| 675 | + | if a.tabs == nil || a.tabs.Count() == 0 { | |
| 676 | + | a.activateBrowserWindow(created) | |
| 677 | + | return nil | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | var openErr error | |
| 681 | + | if a.tabCoord != nil { | |
| 682 | + | openErr = a.openFreshWindowWithTabCoord(ctx, url, created) | |
| 683 | + | } else if a.tabsUC == nil { | |
| 684 | + | a.activateBrowserWindow(created) | |
| 685 | + | return nil | |
| 686 | + | } else { | |
| 687 | + | openErr = a.openFreshWindowWithTabsUC(ctx, url, created) | |
| 688 | + | } | |
| 689 | + | if openErr != nil { | |
| 690 | + | return openErr | |
| 691 | + | } | |
| 692 | + | a.activateBrowserWindow(created) | |
| 693 | + | return nil | |
| 694 | + | } | |
| 517 | 695 | ||
| 518 | - | // Add to main window overlay | |
| 519 | - | pickerWidget := a.accentPicker.Widget() | |
| 520 | - | if pickerWidget != nil { | |
| 521 | - | gtkWidget := pickerWidget.GtkWidget() | |
| 522 | - | if gtkWidget != nil { | |
| 523 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 696 | + | func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error { | |
| 697 | + | previousMainWindow := a.mainWindow | |
| 698 | + | if created.mainWindow != nil { | |
| 699 | + | a.tabCoord.SetMainWindow(created.mainWindow) | |
| 700 | + | } | |
| 701 | + | a.tabCreationWindow = created | |
| 702 | + | defer func() { | |
| 703 | + | a.tabCreationWindow = nil | |
| 704 | + | a.tabCoord.SetMainWindow(previousMainWindow) | |
| 705 | + | }() | |
| 706 | + | ||
| 707 | + | createdTab, createErr := a.tabCoord.Create(ctx, url) | |
| 708 | + | if createErr != nil { | |
| 709 | + | a.removeBrowserWindow(created.id) | |
| 710 | + | if created.mainWindow != nil { | |
| 711 | + | created.mainWindow.Destroy() | |
| 712 | + | } | |
| 713 | + | return createErr | |
| 714 | + | } | |
| 715 | + | if a.workspaceViews[createdTab.ID] == nil { | |
| 716 | + | if a.tabs != nil { | |
| 717 | + | a.tabs.Remove(createdTab.ID) | |
| 524 | 718 | } | |
| 719 | + | tabBar := a.mainWindow.TabBar() | |
| 720 | + | if created.mainWindow != nil && a.tabCoord != nil { | |
| 721 | + | if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil { | |
| 722 | + | tabBar = targetTabBar | |
| 723 | + | } | |
| 724 | + | } | |
| 725 | + | if tabBar != nil { | |
| 726 | + | tabBar.RemoveTab(createdTab.ID) | |
| 727 | + | tabBar.SetActive(a.tabs.ActiveTabID) | |
| 728 | + | } | |
| 729 | + | a.removeBrowserWindow(created.id) | |
| 730 | + | if created.mainWindow != nil { | |
| 731 | + | created.mainWindow.Destroy() | |
| 732 | + | } | |
| 733 | + | return fmt.Errorf("workspace view not created for tab %s", createdTab.ID) | |
| 734 | + | } | |
| 735 | + | a.setBrowserWindowForTab(createdTab.ID, created) | |
| 736 | + | created.activeTabID = createdTab.ID | |
| 737 | + | if created.mainWindow != nil { | |
| 738 | + | created.mainWindow.Show() | |
| 525 | 739 | } | |
| 740 | + | return nil | |
| 741 | + | } | |
| 526 | 742 | ||
| 527 | - | // Use focus provider from deps (tracks which input has focus) | |
| 528 | - | a.accentFocusProvider = a.deps.AccentFocusProvider | |
| 743 | + | func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error { | |
| 744 | + | output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{ | |
| 745 | + | TabList: a.tabs, | |
| 746 | + | InitialURL: url, | |
| 747 | + | }) | |
| 748 | + | if err != nil { | |
| 749 | + | a.removeBrowserWindow(created.id) | |
| 750 | + | if created.mainWindow != nil { | |
| 751 | + | created.mainWindow.Destroy() | |
| 752 | + | } | |
| 753 | + | return err | |
| 754 | + | } | |
| 755 | + | a.setBrowserWindowForTab(output.Tab.ID, created) | |
| 756 | + | created.activeTabID = output.Tab.ID | |
| 757 | + | if a.widgetFactory != nil { | |
| 758 | + | a.tabCreationWindow = created | |
| 759 | + | defer func() { | |
| 760 | + | a.tabCreationWindow = nil | |
| 761 | + | }() | |
| 762 | + | a.createWorkspaceView(ctx, output.Tab) | |
| 763 | + | a.switchWorkspaceView(ctx, output.Tab.ID) | |
| 764 | + | } | |
| 765 | + | if a.workspaceViews[output.Tab.ID] == nil { | |
| 766 | + | if a.tabs != nil { | |
| 767 | + | a.tabs.Remove(output.Tab.ID) | |
| 768 | + | } | |
| 769 | + | var tabBar *component.TabBar | |
| 770 | + | if a.mainWindow != nil { | |
| 771 | + | tabBar = a.mainWindow.TabBar() | |
| 772 | + | } | |
| 773 | + | if created.mainWindow != nil && created.mainWindow.TabBar() != nil { | |
| 774 | + | tabBar = created.mainWindow.TabBar() | |
| 775 | + | } | |
| 776 | + | if tabBar != nil { | |
| 777 | + | tabBar.RemoveTab(output.Tab.ID) | |
| 778 | + | if a.tabs != nil { | |
| 779 | + | tabBar.SetActive(a.tabs.ActiveTabID) | |
| 780 | + | } | |
| 781 | + | } | |
| 782 | + | a.removeBrowserWindow(created.id) | |
| 783 | + | if created.mainWindow != nil { | |
| 784 | + | created.mainWindow.Destroy() | |
| 785 | + | } | |
| 786 | + | return fmt.Errorf("workspace view not created for tab %s", output.Tab.ID) | |
| 787 | + | } | |
| 788 | + | if created.mainWindow != nil { | |
| 789 | + | created.mainWindow.Show() | |
| 790 | + | } | |
| 791 | + | return nil | |
| 792 | + | } | |
| 529 | 793 | ||
| 530 | - | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |
| 531 | - | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |
| 532 | - | a.accentFocusProvider, | |
| 533 | - | a.accentPicker, | |
| 534 | - | func(fn func()) { | |
| 535 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 536 | - | fn() | |
| 537 | - | return false // Don't repeat | |
| 538 | - | }) | |
| 539 | - | glib.IdleAdd(&cb, 0) | |
| 540 | - | }, | |
| 541 | - | ) | |
| 794 | + | func (a *App) initialWindowURL() string { | |
| 795 | + | if a.deps != nil && a.deps.InitialURL != "" { | |
| 796 | + | return a.deps.InitialURL | |
| 797 | + | } | |
| 798 | + | return "dumb://history" | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | func (a *App) initLayoutInfrastructure() { | |
| 802 | + | // Initialize widget factory for pane layout. | |
| 803 | + | a.widgetFactory = layout.NewGtkWidgetFactory() | |
| 804 | + | ||
| 805 | + | // Initialize stacked pane manager for incremental widget operations. | |
| 806 | + | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |
| 807 | + | } | |
| 542 | 808 | ||
| 543 | - | log.Debug().Msg("accent picker initialized") | |
| 809 | + | func (a *App) installCrashReportNotifier(ctx context.Context) { | |
| 810 | + | if a.deps == nil { | |
| 811 | + | return | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | a.deps.OnCrashReportsDetected = func(paths []string) { | |
| 815 | + | a.showCrashReportToast(ctx, paths) | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | func (a *App) initFocusManager() { | |
| 820 | + | a.focusMgr = focus.NewManager(a.panesUC) | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | func (a *App) wireSessionManagerShortcut() { | |
| 824 | + | if a.kbDispatcher == nil { | |
| 825 | + | return | |
| 826 | + | } | |
| 827 | + | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error { | |
| 828 | + | bw := a.ownerOrLastFocusedBrowserWindow("", paneID) | |
| 829 | + | if bw == nil || bw.sessionManager == nil { | |
| 830 | + | return nil | |
| 831 | + | } | |
| 832 | + | bw.sessionManager.Toggle(ctx) | |
| 833 | + | return nil | |
| 834 | + | }) | |
| 544 | 835 | } | |
| 545 | 836 | ||
| 546 | 837 | // registerAccentHandlers registers the accent key press/release message handlers | |
| 547 | - | // with the router. Must be called after initAccentPicker so insertAccentUC is non-nil. | |
| 838 | + | // with the router. | |
| 548 | 839 | func (a *App) registerAccentHandlers(ctx context.Context) { | |
| 549 | 840 | log := logging.FromContext(ctx) | |
| 550 | 841 | ||
| 551 | - | if a.insertAccentUC == nil { | |
| 552 | - | log.Debug().Msg("insertAccentUC not available, skipping accent handler registration") | |
| 553 | - | return | |
| 554 | - | } | |
| 555 | 842 | if a.engine == nil { | |
| 556 | 843 | log.Debug().Msg("engine not available, skipping accent handler registration") | |
| 557 | 844 | return | |
| 558 | 845 | } | |
| 559 | 846 | ||
| 560 | - | if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil { | |
| 847 | + | if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil { | |
| 561 | 848 | log.Error().Err(err).Msg("failed to register accent handlers") | |
| 562 | 849 | } else { | |
| 563 | 850 | log.Info().Msg("registered accent key handlers") | |
| 564 | 851 | } | |
| 565 | 852 | } | |
| 566 | 853 | ||
| 854 | + | func (a *App) activeAccentHandler() input.AccentHandler { | |
| 855 | + | bw := a.lastFocusedBrowserWindow() | |
| 856 | + | if bw == nil { | |
| 857 | + | return nil | |
| 858 | + | } | |
| 859 | + | return bw.insertAccentUC | |
| 860 | + | } | |
| 861 | + | ||
| 567 | 862 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 568 | 863 | log := logging.FromContext(ctx) | |
| 569 | 864 | ||
| @@ -596,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 596 | 891 | } | |
| 597 | 892 | ||
| 598 | 893 | // Create download event adapter to show toasts. | |
| 599 | - | eventAdapter := &downloadEventAdapter{app: a} | |
| 894 | + | eventAdapter := newDownloadEventAdapter(a) | |
| 600 | 895 | ||
| 601 | 896 | // Create use case for preparing download destinations with file deduplication. | |
| 602 | 897 | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem) | |
| @@ -611,29 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 611 | 906 | ||
| 612 | 907 | // downloadEventAdapter implements port.DownloadEventHandler and shows toasts. | |
| 613 | 908 | type downloadEventAdapter struct { | |
| 614 | - | app *App | |
| 909 | + | app *App | |
| 910 | + | mu sync.Mutex | |
| 911 | + | active map[string]downloadToastState | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | type downloadToastState struct { | |
| 915 | + | filename string | |
| 916 | + | percent int | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | type downloadToastSpec struct { | |
| 920 | + | message string | |
| 921 | + | level component.ToastLevel | |
| 922 | + | duration int | |
| 923 | + | show bool | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | func newDownloadEventAdapter(app *App) *downloadEventAdapter { | |
| 927 | + | return &downloadEventAdapter{ | |
| 928 | + | app: app, | |
| 929 | + | active: make(map[string]downloadToastState), | |
| 930 | + | } | |
| 615 | 931 | } | |
| 616 | 932 | ||
| 617 | 933 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | |
| 934 | + | spec := d.toastSpecForEvent(event) | |
| 935 | + | if !spec.show { | |
| 936 | + | return | |
| 937 | + | } | |
| 938 | + | ||
| 618 | 939 | // Must schedule on GTK main thread. | |
| 619 | 940 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 620 | - | if d.app.appToaster == nil { | |
| 621 | - | return false | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | switch event.Type { | |
| 625 | - | case port.DownloadEventStarted: | |
| 626 | - | d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo) | |
| 627 | - | case port.DownloadEventFinished: | |
| 628 | - | d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | |
| 629 | - | case port.DownloadEventFailed: | |
| 630 | - | d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError) | |
| 631 | - | } | |
| 941 | + | d.app.showToastOnLastFocusedBrowserWindow( | |
| 942 | + | ctx, | |
| 943 | + | spec.message, | |
| 944 | + | spec.level, | |
| 945 | + | component.WithDuration(spec.duration), | |
| 946 | + | ) | |
| 632 | 947 | return false | |
| 633 | 948 | }) | |
| 634 | 949 | glib.IdleAdd(&cb, 0) | |
| 635 | 950 | } | |
| 636 | 951 | ||
| 952 | + | func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec { | |
| 953 | + | key := downloadToastKey(event) | |
| 954 | + | filename := event.Filename | |
| 955 | + | if filename == "" { | |
| 956 | + | filename = filepath.Base(event.Destination) | |
| 957 | + | } | |
| 958 | + | if filename == "" { | |
| 959 | + | filename = "download" | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | d.mu.Lock() | |
| 963 | + | defer d.mu.Unlock() | |
| 964 | + | ||
| 965 | + | state := d.active[key] | |
| 966 | + | if state.filename == "" { | |
| 967 | + | state.filename = filename | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | switch event.Type { | |
| 971 | + | case port.DownloadEventStarted: | |
| 972 | + | state.percent = 0 | |
| 973 | + | d.active[key] = state | |
| 974 | + | return downloadToastSpec{ | |
| 975 | + | message: fmt.Sprintf("Download started: %s (0%%)", state.filename), | |
| 976 | + | level: component.ToastInfo, | |
| 977 | + | duration: 0, | |
| 978 | + | show: true, | |
| 979 | + | } | |
| 980 | + | case port.DownloadEventProgress: | |
| 981 | + | percent := downloadProgressPercent(event.Progress) | |
| 982 | + | if percent <= state.percent { | |
| 983 | + | if _, ok := d.active[key]; !ok { | |
| 984 | + | d.active[key] = state | |
| 985 | + | } | |
| 986 | + | return downloadToastSpec{} | |
| 987 | + | } | |
| 988 | + | state.percent = percent | |
| 989 | + | d.active[key] = state | |
| 990 | + | return downloadToastSpec{ | |
| 991 | + | message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent), | |
| 992 | + | level: component.ToastInfo, | |
| 993 | + | duration: 0, | |
| 994 | + | show: true, | |
| 995 | + | } | |
| 996 | + | case port.DownloadEventFinished: | |
| 997 | + | delete(d.active, key) | |
| 998 | + | return downloadToastSpec{ | |
| 999 | + | message: "Download complete: " + state.filename, | |
| 1000 | + | level: component.ToastSuccess, | |
| 1001 | + | duration: downloadToastTerminalDuration, | |
| 1002 | + | show: true, | |
| 1003 | + | } | |
| 1004 | + | case port.DownloadEventFailed: | |
| 1005 | + | delete(d.active, key) | |
| 1006 | + | return downloadToastSpec{ | |
| 1007 | + | message: "Download failed: " + state.filename, | |
| 1008 | + | level: component.ToastError, | |
| 1009 | + | duration: downloadToastTerminalDuration, | |
| 1010 | + | show: true, | |
| 1011 | + | } | |
| 1012 | + | default: | |
| 1013 | + | return downloadToastSpec{} | |
| 1014 | + | } | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | func downloadToastKey(event port.DownloadEvent) string { | |
| 1018 | + | if event.Destination != "" { | |
| 1019 | + | return event.Destination | |
| 1020 | + | } | |
| 1021 | + | if event.Filename != "" { | |
| 1022 | + | return event.Filename | |
| 1023 | + | } | |
| 1024 | + | return "download" | |
| 1025 | + | } | |
| 1026 | + | ||
| 1027 | + | func downloadProgressPercent(progress float64) int { | |
| 1028 | + | if progress <= 0 { | |
| 1029 | + | return 0 | |
| 1030 | + | } | |
| 1031 | + | if progress >= 1 { | |
| 1032 | + | return 100 | |
| 1033 | + | } | |
| 1034 | + | percent := int(progress*100 + downloadProgressRoundBias) | |
| 1035 | + | if percent < 0 { | |
| 1036 | + | return 0 | |
| 1037 | + | } | |
| 1038 | + | if percent > 100 { | |
| 1039 | + | return 100 | |
| 1040 | + | } | |
| 1041 | + | return percent | |
| 1042 | + | } | |
| 1043 | + | ||
| 637 | 1044 | // autoCopyConfigFn implements port.AutoCopyConfig using a function closure. | |
| 638 | 1045 | type autoCopyConfigFn struct { | |
| 639 | 1046 | fn func() bool | |
| @@ -647,57 +1054,51 @@ func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool { | |||
| 647 | 1054 | } | |
| 648 | 1055 | ||
| 649 | 1056 | func (a *App) initKeyboardHandler(ctx context.Context) { | |
| 1057 | + | bw := a.browserWindowForMainWindow(a.mainWindow) | |
| 1058 | + | if bw == nil { | |
| 1059 | + | return | |
| 1060 | + | } | |
| 1061 | + | a.initBrowserWindowInput(ctx, bw) | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |
| 650 | 1065 | log := logging.FromContext(ctx) | |
| 651 | 1066 | ||
| 652 | - | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | |
| 1067 | + | if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil { | |
| 1068 | + | return | |
| 1069 | + | } | |
| 1070 | + | if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil { | |
| 653 | 1071 | return | |
| 654 | 1072 | } | |
| 655 | 1073 | ||
| 656 | - | // Create keyboard handler and wire to dispatcher. | |
| 657 | - | a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 658 | - | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | |
| 1074 | + | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 1075 | + | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { | |
| 1076 | + | a.activateBrowserWindow(bw) | |
| 659 | 1077 | if action == input.ActionClosePane { | |
| 660 | - | if a.closeAndReleaseActiveFloatingPane(ctx) { | |
| 1078 | + | if a.closeAndReleaseActiveFloatingPane(actionCtx) { | |
| 661 | 1079 | return nil | |
| 662 | 1080 | } | |
| 663 | 1081 | } | |
| 664 | - | return a.kbDispatcher.Dispatch(ctx, action) | |
| 1082 | + | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 665 | 1083 | }) | |
| 666 | - | a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool { | |
| 667 | - | return a.handleGlobalEscape(ctx) | |
| 1084 | + | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | |
| 1085 | + | a.activateBrowserWindow(bw) | |
| 1086 | + | return a.handleGlobalEscape(escapeCtx) | |
| 668 | 1087 | }) | |
| 669 | - | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 1088 | + | bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 1089 | + | a.activateBrowserWindow(bw) | |
| 670 | 1090 | a.handleModeChange(ctx, from, to) | |
| 671 | 1091 | }) | |
| 672 | - | // Wire key routing policy. | |
| 673 | - | // Determines how each key event should be handled based on UI state: | |
| 674 | - | // - Overlays visible (session manager, tab picker): pass all keys to widget | |
| 675 | - | // - Omnibox visible: accent detection for text keys, shortcuts for modified keys | |
| 676 | - | // - WebView focused: pass text/dead keys to WebKit IM for native compose, | |
| 677 | - | // intercept shortcut-modified keys (Ctrl+, Alt+) | |
| 678 | - | // - Default: handle through shortcut system | |
| 679 | - | a.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 680 | - | // Overlays take full keyboard control | |
| 681 | - | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 1092 | + | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1093 | + | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 682 | 1094 | return input.RoutePassToWidget | |
| 683 | 1095 | } | |
| 684 | - | if a.tabPicker != nil && a.tabPicker.IsVisible() { | |
| 1096 | + | if bw.tabPicker != nil && bw.tabPicker.IsVisible() { | |
| 685 | 1097 | return input.RoutePassToWidget | |
| 686 | 1098 | } | |
| 687 | 1099 | ||
| 688 | - | wsView := a.activeWorkspaceView() | |
| 689 | - | if wsView == nil { | |
| 690 | - | return input.RouteHandleShortcuts | |
| 691 | - | } | |
| 692 | - | ||
| 693 | - | // Check actual focus rather than visibility: find bar can remain | |
| 694 | - | // visible after focus returns to the WebView, so visibility alone | |
| 695 | - | // would misroute keys. | |
| 696 | 1100 | if a.accentFocusProvider != nil { | |
| 697 | 1101 | if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok { | |
| 698 | - | // GTK Entry focused (omnibox or find bar): Alt-modified keys go to | |
| 699 | - | // shortcuts (pane navigation), other keys pass through to the widget's | |
| 700 | - | // own capture-phase key controller (which handles accent detection) | |
| 701 | 1102 | if kc.Modifiers&input.ModAlt != 0 { | |
| 702 | 1103 | return input.RouteHandleShortcuts | |
| 703 | 1104 | } | |
| @@ -705,8 +1106,6 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 705 | 1106 | } | |
| 706 | 1107 | } | |
| 707 | 1108 | ||
| 708 | - | // WebView focused: text/dead keys pass through for native IM compose, | |
| 709 | - | // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system | |
| 710 | 1109 | if input.IsShortcutModified(kc.Modifiers) { | |
| 711 | 1110 | return input.RouteHandleShortcuts | |
| 712 | 1111 | } | |
| @@ -714,37 +1113,48 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 714 | 1113 | return input.RoutePassToWidget | |
| 715 | 1114 | } | |
| 716 | 1115 | ||
| 717 | - | // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system | |
| 718 | 1116 | return input.RouteHandleShortcuts | |
| 719 | 1117 | }) | |
| 720 | - | // Wire accent handler for dead keys support | |
| 721 | - | if a.insertAccentUC != nil { | |
| 722 | - | a.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |
| 723 | - | } | |
| 724 | - | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | |
| 1118 | + | bw.keyboardHandler.SetAccentHandler(a) | |
| 1119 | + | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | |
| 725 | 1120 | ||
| 726 | - | // Create global shortcut handler for Alt+1-9 tab switching. | |
| 727 | - | // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |
| 728 | - | // to intercept shortcuts even when WebView has focus. | |
| 729 | - | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| 1121 | + | bw.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| 730 | 1122 | ctx, | |
| 731 | - | a.mainWindow.Window(), | |
| 1123 | + | bw.mainWindow.Window(), | |
| 732 | 1124 | &a.deps.Config.Workspace, | |
| 733 | 1125 | &a.deps.Config.Session, | |
| 734 | - | a.keyboardHandler, | |
| 735 | - | func(ctx context.Context, action input.Action) error { | |
| 736 | - | return a.kbDispatcher.Dispatch(ctx, action) | |
| 1126 | + | bw.keyboardHandler, | |
| 1127 | + | func(actionCtx context.Context, action input.Action) error { | |
| 1128 | + | a.activateBrowserWindow(bw) | |
| 1129 | + | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 737 | 1130 | }, | |
| 738 | 1131 | ) | |
| 739 | - | if a.globalShortcutHandler == nil { | |
| 740 | - | log.Warn().Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus") | |
| 1132 | + | if bw.globalShortcutHandler == nil { | |
| 1133 | + | log.Warn().Str("window_id", bw.id).Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus") | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | a.activateBrowserWindow(bw) | |
| 1137 | + | } | |
| 1138 | + | ||
| 1139 | + | func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) { | |
| 1140 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil || a.tabCoord == nil { | |
| 1141 | + | return | |
| 741 | 1142 | } | |
| 1143 | + | bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | |
| 1144 | + | a.activateBrowserWindow(bw) | |
| 1145 | + | if err := a.tabCoord.Switch(ctx, tabID); err != nil { | |
| 1146 | + | logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed") | |
| 1147 | + | return | |
| 1148 | + | } | |
| 1149 | + | bw.activeTabID = tabID | |
| 1150 | + | }) | |
| 742 | 1151 | } | |
| 743 | 1152 | ||
| 744 | 1153 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |
| 745 | 1154 | // (omnibox and find bar). Returns true if the key was consumed. | |
| 746 | 1155 | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | |
| 747 | - | if a.insertAccentUC == nil { | |
| 1156 | + | handler := a.activeAccentHandler() | |
| 1157 | + | if handler == nil { | |
| 748 | 1158 | return false | |
| 749 | 1159 | } | |
| 750 | 1160 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| @@ -752,7 +1162,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 752 | 1162 | } | |
| 753 | 1163 | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 754 | 1164 | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 755 | - | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 1165 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |
| 756 | 1166 | } | |
| 757 | 1167 | return false | |
| 758 | 1168 | } | |
| @@ -760,14 +1170,47 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 760 | 1170 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | |
| 761 | 1171 | // (omnibox and find bar). | |
| 762 | 1172 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |
| 763 | - | if a.insertAccentUC == nil { | |
| 1173 | + | handler := a.activeAccentHandler() | |
| 1174 | + | if handler == nil { | |
| 764 | 1175 | return | |
| 765 | 1176 | } | |
| 766 | 1177 | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 767 | - | a.insertAccentUC.OnKeyReleased(ctx, char) | |
| 1178 | + | handler.OnKeyReleased(ctx, char) | |
| 768 | 1179 | } | |
| 769 | 1180 | } | |
| 770 | 1181 | ||
| 1182 | + | func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | |
| 1183 | + | handler := a.activeAccentHandler() | |
| 1184 | + | if handler == nil { | |
| 1185 | + | return false | |
| 1186 | + | } | |
| 1187 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |
| 1188 | + | } | |
| 1189 | + | ||
| 1190 | + | func (a *App) OnKeyReleased(ctx context.Context, char rune) { | |
| 1191 | + | handler := a.activeAccentHandler() | |
| 1192 | + | if handler == nil { | |
| 1193 | + | return | |
| 1194 | + | } | |
| 1195 | + | handler.OnKeyReleased(ctx, char) | |
| 1196 | + | } | |
| 1197 | + | ||
| 1198 | + | func (a *App) IsPickerVisible() bool { | |
| 1199 | + | handler := a.activeAccentHandler() | |
| 1200 | + | if handler == nil { | |
| 1201 | + | return false | |
| 1202 | + | } | |
| 1203 | + | return handler.IsPickerVisible() | |
| 1204 | + | } | |
| 1205 | + | ||
| 1206 | + | func (a *App) Cancel(ctx context.Context) { | |
| 1207 | + | handler := a.activeAccentHandler() | |
| 1208 | + | if handler == nil { | |
| 1209 | + | return | |
| 1210 | + | } | |
| 1211 | + | handler.Cancel(ctx) | |
| 1212 | + | } | |
| 1213 | + | ||
| 771 | 1214 | type omniboxCallbacks struct { | |
| 772 | 1215 | OnNavigate func(url string) | |
| 773 | 1216 | OnToast func(ctx context.Context, message string, level component.ToastLevel) | |
| @@ -802,6 +1245,7 @@ func buildOmniboxConfig( | |||
| 802 | 1245 | ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts), | |
| 803 | 1246 | DefaultSearch: deps.Config.DefaultSearchEngine, | |
| 804 | 1247 | InitialBehavior: deps.Config.Omnibox.InitialBehavior, | |
| 1248 | + | MostVisitedDays: deps.Config.Omnibox.MostVisitedDays, | |
| 805 | 1249 | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, | |
| 806 | 1250 | UIScale: deps.Config.DefaultUIScale, | |
| 807 | 1251 | OnNavigate: callbacks.OnNavigate, | |
| @@ -930,89 +1374,18 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||
| 930 | 1374 | } | |
| 931 | 1375 | } | |
| 932 | 1376 | ||
| 933 | - | func (a *App) initSessionManager(ctx context.Context) { | |
| 934 | - | log := logging.FromContext(ctx) | |
| 935 | - | ||
| 936 | - | if a.deps == nil { | |
| 937 | - | log.Debug().Msg("deps not available, skipping session manager") | |
| 938 | - | return | |
| 939 | - | } | |
| 940 | - | ||
| 941 | - | // Session manager can work without a repo - it just shows an empty list | |
| 942 | - | var listSessionsUC *usecase.ListSessionsUseCase | |
| 943 | - | var deleteSessionUC *usecase.DeleteSessionUseCase | |
| 944 | - | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |
| 945 | - | listSessionsUC = usecase.NewListSessionsUseCase( | |
| 946 | - | a.deps.SessionRepo, | |
| 947 | - | a.deps.SessionStateRepo, | |
| 948 | - | ) | |
| 949 | - | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |
| 950 | - | a.deps.SessionStateRepo, | |
| 951 | - | a.deps.SessionRepo, | |
| 952 | - | ) | |
| 953 | - | } | |
| 954 | - | ||
| 955 | - | // Use injected session spawner for restoration | |
| 956 | - | spawner := a.deps.SessionSpawner | |
| 957 | - | ||
| 958 | - | // Create session manager component | |
| 959 | - | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| 960 | - | ListSessionsUC: listSessionsUC, | |
| 961 | - | DeleteSessionUC: deleteSessionUC, | |
| 962 | - | CurrentSession: a.deps.CurrentSessionID, | |
| 963 | - | UIScale: a.deps.Config.DefaultUIScale, | |
| 964 | - | OnClose: func() { | |
| 965 | - | log.Debug().Msg("session manager closed") | |
| 966 | - | }, | |
| 967 | - | OnOpen: func(sessionID entity.SessionID) { | |
| 968 | - | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |
| 969 | - | if spawner == nil { | |
| 970 | - | log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available") | |
| 971 | - | return | |
| 972 | - | } | |
| 973 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 974 | - | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |
| 975 | - | } | |
| 976 | - | }, | |
| 977 | - | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |
| 978 | - | if a.appToaster != nil { | |
| 979 | - | a.appToaster.Show(ctx, message, level) | |
| 980 | - | } | |
| 981 | - | }, | |
| 982 | - | }) | |
| 983 | - | ||
| 984 | - | if a.sessionManager == nil { | |
| 985 | - | log.Warn().Msg("failed to create session manager") | |
| 986 | - | return | |
| 987 | - | } | |
| 988 | - | ||
| 989 | - | // Add session manager to main window overlay | |
| 990 | - | if a.mainWindow != nil { | |
| 991 | - | widget := a.sessionManager.Widget() | |
| 992 | - | if widget != nil { | |
| 993 | - | a.mainWindow.AddOverlay(widget) | |
| 994 | - | } | |
| 995 | - | } | |
| 996 | - | ||
| 997 | - | // Wire session manager action to keyboard dispatcher | |
| 998 | - | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error { | |
| 999 | - | a.ToggleSessionManager(ctx) | |
| 1000 | - | return nil | |
| 1001 | - | }) | |
| 1002 | - | ||
| 1003 | - | log.Debug().Msg("session manager initialized") | |
| 1004 | - | } | |
| 1005 | - | ||
| 1006 | 1377 | // ToggleSessionManager shows or hides the session manager. | |
| 1007 | 1378 | func (a *App) ToggleSessionManager(ctx context.Context) { | |
| 1008 | - | if a.sessionManager == nil { | |
| 1379 | + | bw := a.lastFocusedBrowserWindow() | |
| 1380 | + | if bw == nil || bw.sessionManager == nil { | |
| 1009 | 1381 | return | |
| 1010 | 1382 | } | |
| 1011 | - | a.sessionManager.Toggle(ctx) | |
| 1383 | + | bw.sessionManager.Toggle(ctx) | |
| 1012 | 1384 | } | |
| 1013 | 1385 | ||
| 1014 | 1386 | func (a *App) attachTabPickerToActivePane() { | |
| 1015 | - | if a.tabPicker == nil || a.widgetFactory == nil { | |
| 1387 | + | bw := a.lastFocusedBrowserWindow() | |
| 1388 | + | if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil { | |
| 1016 | 1389 | return | |
| 1017 | 1390 | } | |
| 1018 | 1391 | wsView := a.activeWorkspaceView() | |
| @@ -1033,24 +1406,24 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1033 | 1406 | return | |
| 1034 | 1407 | } | |
| 1035 | 1408 | ||
| 1036 | - | if a.tabPickerWidget == nil { | |
| 1037 | - | a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory) | |
| 1038 | - | if a.tabPickerWidget == nil { | |
| 1409 | + | if bw.tabPickerWidget == nil { | |
| 1410 | + | bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory) | |
| 1411 | + | if bw.tabPickerWidget == nil { | |
| 1039 | 1412 | return | |
| 1040 | 1413 | } | |
| 1041 | 1414 | } | |
| 1042 | 1415 | ||
| 1043 | 1416 | // If currently attached to a different pane overlay, detach. | |
| 1044 | - | if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID { | |
| 1417 | + | if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID { | |
| 1045 | 1418 | for _, view := range a.workspaceViews { | |
| 1046 | 1419 | if view == nil { | |
| 1047 | 1420 | continue | |
| 1048 | 1421 | } | |
| 1049 | - | if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil { | |
| 1050 | - | if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | |
| 1051 | - | oldPV.RemoveOverlayWidget(a.tabPickerWidget) | |
| 1422 | + | if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil { | |
| 1423 | + | if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | |
| 1424 | + | oldPV.RemoveOverlayWidget(bw.tabPickerWidget) | |
| 1052 | 1425 | } else if parent != nil { | |
| 1053 | - | a.tabPickerWidget.Unparent() | |
| 1426 | + | bw.tabPickerWidget.Unparent() | |
| 1054 | 1427 | } | |
| 1055 | 1428 | break | |
| 1056 | 1429 | } | |
| @@ -1058,20 +1431,21 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1058 | 1431 | } | |
| 1059 | 1432 | ||
| 1060 | 1433 | // Ensure the widget can be reparented. | |
| 1061 | - | if parent := a.tabPickerWidget.GetParent(); parent != nil { | |
| 1062 | - | a.tabPickerWidget.Unparent() | |
| 1434 | + | if parent := bw.tabPickerWidget.GetParent(); parent != nil { | |
| 1435 | + | bw.tabPickerWidget.Unparent() | |
| 1063 | 1436 | } | |
| 1064 | 1437 | ||
| 1065 | - | a.tabPicker.SetParentOverlay(pv.Overlay()) | |
| 1066 | - | pv.AddOverlayWidget(a.tabPickerWidget) | |
| 1067 | - | a.tabPickerPaneID = activePaneID | |
| 1438 | + | bw.tabPicker.SetParentOverlay(pv.Overlay()) | |
| 1439 | + | pv.AddOverlayWidget(bw.tabPickerWidget) | |
| 1440 | + | bw.tabPickerPaneID = activePaneID | |
| 1068 | 1441 | } | |
| 1069 | 1442 | ||
| 1070 | 1443 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |
| 1071 | 1444 | if a.movePaneToTabUC == nil { | |
| 1072 | 1445 | return nil | |
| 1073 | 1446 | } | |
| 1074 | - | if a.tabPicker == nil { | |
| 1447 | + | bw := a.lastFocusedBrowserWindow() | |
| 1448 | + | if bw == nil || bw.tabPicker == nil { | |
| 1075 | 1449 | return nil | |
| 1076 | 1450 | } | |
| 1077 | 1451 | ||
| @@ -1103,7 +1477,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1103 | 1477 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | |
| 1104 | 1478 | ||
| 1105 | 1479 | a.attachTabPickerToActivePane() | |
| 1106 | - | a.tabPicker.Show(ctx, items) | |
| 1480 | + | bw.tabPicker.Show(ctx, items) | |
| 1107 | 1481 | return nil | |
| 1108 | 1482 | } | |
| 1109 | 1483 | ||
| @@ -1134,6 +1508,17 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1134 | 1508 | return nil | |
| 1135 | 1509 | } | |
| 1136 | 1510 | ||
| 1511 | + | sourceWindow := a.browserWindowForTab(sourceTab.ID) | |
| 1512 | + | if sourceWindow == nil { | |
| 1513 | + | sourceWindow = a.browserWindowForMainWindow(a.mainWindow) | |
| 1514 | + | } | |
| 1515 | + | targetWindow := sourceWindow | |
| 1516 | + | if targetTabID != "" { | |
| 1517 | + | if owner := a.browserWindowForTab(targetTabID); owner != nil { | |
| 1518 | + | targetWindow = owner | |
| 1519 | + | } | |
| 1520 | + | } | |
| 1521 | + | ||
| 1137 | 1522 | out, err := a.movePaneToTabUC.Execute(*in) | |
| 1138 | 1523 | if err != nil { | |
| 1139 | 1524 | return err | |
| @@ -1141,14 +1526,24 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1141 | 1526 | if out == nil || out.TargetTab == nil { | |
| 1142 | 1527 | return nil | |
| 1143 | 1528 | } | |
| 1529 | + | if out.NewTabCreated && targetWindow != nil { | |
| 1530 | + | a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow) | |
| 1531 | + | } | |
| 1144 | 1532 | ||
| 1145 | - | a.applyMovePaneToTabUI(ctx, out, sourceTab) | |
| 1146 | - | a.switchToTargetTabIfConfigured(ctx, out) | |
| 1533 | + | a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow) | |
| 1534 | + | a.switchToTargetTabIfConfigured(ctx, out, targetWindow) | |
| 1147 | 1535 | a.updateTabBarVisibility(ctx) | |
| 1148 | 1536 | a.MarkDirty() | |
| 1149 | 1537 | return nil | |
| 1150 | 1538 | } | |
| 1151 | 1539 | ||
| 1540 | + | func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error { | |
| 1541 | + | if bw != nil { | |
| 1542 | + | a.activateBrowserWindow(bw) | |
| 1543 | + | } | |
| 1544 | + | return a.MoveActivePaneToTab(ctx, targetTabID) | |
| 1545 | + | } | |
| 1546 | + | ||
| 1152 | 1547 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | |
| 1153 | 1548 | if a.movePaneToTabUC == nil { | |
| 1154 | 1549 | return nil, nil | |
| @@ -1171,7 +1566,13 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1171 | 1566 | return in, activeTab | |
| 1172 | 1567 | } | |
| 1173 | 1568 | ||
| 1174 | - | func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) { | |
| 1569 | + | func (a *App) applyMovePaneToTabUI( | |
| 1570 | + | ctx context.Context, | |
| 1571 | + | out *usecase.MovePaneToTabOutput, | |
| 1572 | + | sourceTab *entity.Tab, | |
| 1573 | + | sourceWindow *browserWindow, | |
| 1574 | + | targetWindow *browserWindow, | |
| 1575 | + | ) { | |
| 1175 | 1576 | if out == nil || out.TargetTab == nil { | |
| 1176 | 1577 | return | |
| 1177 | 1578 | } | |
| @@ -1181,10 +1582,10 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||
| 1181 | 1582 | } | |
| 1182 | 1583 | ||
| 1183 | 1584 | if out.SourceTabClosed { | |
| 1184 | - | a.removeSourceTabUI(sourceTabID) | |
| 1585 | + | a.removeSourceTabUI(sourceTabID, sourceWindow) | |
| 1185 | 1586 | } | |
| 1186 | 1587 | if out.NewTabCreated { | |
| 1187 | - | a.ensureTargetTabUI(ctx, out.TargetTab) | |
| 1588 | + | a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow) | |
| 1188 | 1589 | } | |
| 1189 | 1590 | ||
| 1190 | 1591 | if !out.SourceTabClosed { | |
| @@ -1193,20 +1594,20 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||
| 1193 | 1594 | a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab) | |
| 1194 | 1595 | } | |
| 1195 | 1596 | ||
| 1196 | - | func (a *App) removeSourceTabUI(sourceTabID entity.TabID) { | |
| 1597 | + | func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserWindow) { | |
| 1197 | 1598 | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | |
| 1198 | 1599 | delete(a.workspaceViews, sourceTabID) | |
| 1199 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| 1200 | - | a.mainWindow.TabBar().RemoveTab(sourceTabID) | |
| 1600 | + | if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil { | |
| 1601 | + | tabBar.RemoveTab(sourceTabID) | |
| 1201 | 1602 | } | |
| 1202 | 1603 | } | |
| 1203 | 1604 | ||
| 1204 | - | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) { | |
| 1605 | + | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) { | |
| 1205 | 1606 | if tab == nil { | |
| 1206 | 1607 | return | |
| 1207 | 1608 | } | |
| 1208 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| 1209 | - | a.mainWindow.TabBar().AddTab(tab) | |
| 1609 | + | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { | |
| 1610 | + | tabBar.AddTab(tab) | |
| 1210 | 1611 | } | |
| 1211 | 1612 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 1212 | 1613 | } | |
| @@ -1254,12 +1655,19 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor | |||
| 1254 | 1655 | } | |
| 1255 | 1656 | } | |
| 1256 | 1657 | ||
| 1257 | - | func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) { | |
| 1658 | + | func (a *App) switchToTargetTabIfConfigured( | |
| 1659 | + | ctx context.Context, | |
| 1660 | + | out *usecase.MovePaneToTabOutput, | |
| 1661 | + | targetWindow *browserWindow, | |
| 1662 | + | ) { | |
| 1258 | 1663 | if out == nil || out.TargetTab == nil { | |
| 1259 | 1664 | return | |
| 1260 | 1665 | } | |
| 1261 | 1666 | ||
| 1262 | - | switchToTarget := a.deps.Config.Workspace.SwitchToTabOnMove | |
| 1667 | + | switchToTarget := false | |
| 1668 | + | if a.deps != nil && a.deps.Config != nil { | |
| 1669 | + | switchToTarget = a.deps.Config.Workspace.SwitchToTabOnMove | |
| 1670 | + | } | |
| 1263 | 1671 | if out.SourceTabClosed { | |
| 1264 | 1672 | switchToTarget = true | |
| 1265 | 1673 | } | |
| @@ -1268,8 +1676,14 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo | |||
| 1268 | 1676 | } | |
| 1269 | 1677 | ||
| 1270 | 1678 | a.tabs.SetActive(out.TargetTab.ID) | |
| 1271 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| 1272 | - | a.mainWindow.TabBar().SetActive(out.TargetTab.ID) | |
| 1679 | + | if targetWindow != nil { | |
| 1680 | + | targetWindow.activeTabID = out.TargetTab.ID | |
| 1681 | + | } | |
| 1682 | + | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { | |
| 1683 | + | tabBar.SetActive(out.TargetTab.ID) | |
| 1684 | + | } | |
| 1685 | + | if targetWindow != nil { | |
| 1686 | + | a.activateBrowserWindow(targetWindow) | |
| 1273 | 1687 | } | |
| 1274 | 1688 | a.switchWorkspaceView(ctx, out.TargetTab.ID) | |
| 1275 | 1689 | } | |
| @@ -1280,42 +1694,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) { | |||
| 1280 | 1694 | } | |
| 1281 | 1695 | } | |
| 1282 | 1696 | ||
| 1283 | - | func (a *App) initTabPicker(ctx context.Context) { | |
| 1284 | - | log := logging.FromContext(ctx) | |
| 1285 | - | ||
| 1286 | - | if a.deps == nil || a.deps.Config == nil { | |
| 1287 | - | log.Debug().Msg("deps/config not available, skipping tab picker") | |
| 1288 | - | return | |
| 1289 | - | } | |
| 1290 | - | ||
| 1291 | - | a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{ | |
| 1292 | - | UIScale: a.deps.Config.DefaultUIScale, | |
| 1293 | - | OnClose: func() { | |
| 1294 | - | log.Debug().Msg("tab picker closed") | |
| 1295 | - | }, | |
| 1296 | - | OnSelect: func(item component.TabPickerItem) { | |
| 1297 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1298 | - | var targetID entity.TabID | |
| 1299 | - | if !item.IsNew { | |
| 1300 | - | targetID = item.TabID | |
| 1301 | - | } | |
| 1302 | - | if err := a.MoveActivePaneToTab(ctx, targetID); err != nil { | |
| 1303 | - | log.Warn().Err(err).Msg("move pane to tab failed") | |
| 1304 | - | } | |
| 1305 | - | return false | |
| 1306 | - | }) | |
| 1307 | - | glib.IdleAdd(&cb, 0) | |
| 1308 | - | }, | |
| 1309 | - | }) | |
| 1310 | - | ||
| 1311 | - | if a.tabPicker == nil { | |
| 1312 | - | log.Warn().Msg("failed to create tab picker") | |
| 1313 | - | return | |
| 1314 | - | } | |
| 1315 | - | ||
| 1316 | - | log.Debug().Msg("tab picker initialized") | |
| 1317 | - | } | |
| 1318 | - | ||
| 1319 | 1697 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1320 | 1698 | log := logging.FromContext(ctx) | |
| 1321 | 1699 | ||
| @@ -1355,7 +1733,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1355 | 1733 | a.updateCoord = coordinator.NewUpdateCoordinator( | |
| 1356 | 1734 | a.deps.CheckUpdateUC, | |
| 1357 | 1735 | a.deps.ApplyUpdateUC, | |
| 1358 | - | a.appToaster, | |
| 1736 | + | func(ctx context.Context, msg string, level component.ToastLevel) { | |
| 1737 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, level) | |
| 1738 | + | }, | |
| 1359 | 1739 | a.deps.Config.Update.EnableOnStartup, | |
| 1360 | 1740 | a.deps.Config.Update.AutoDownload, | |
| 1361 | 1741 | ) | |
| @@ -1394,29 +1774,59 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1394 | 1774 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | |
| 1395 | 1775 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | |
| 1396 | 1776 | // Show error toast and fall through to create default tab | |
| 1397 | - | if a.appToaster != nil { | |
| 1398 | - | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |
| 1399 | - | } | |
| 1777 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning) | |
| 1400 | 1778 | } else { | |
| 1401 | 1779 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | |
| 1402 | 1780 | // Show success toast for session restoration | |
| 1403 | - | if a.appToaster != nil { | |
| 1404 | - | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |
| 1405 | - | } | |
| 1781 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess) | |
| 1406 | 1782 | return | |
| 1407 | 1783 | } | |
| 1408 | 1784 | } | |
| 1409 | 1785 | ||
| 1410 | 1786 | // Create an initial tab using coordinator. | |
| 1411 | - | initialURL := "dumb://history" | |
| 1412 | - | if a.deps != nil && a.deps.InitialURL != "" { | |
| 1413 | - | initialURL = a.deps.InitialURL | |
| 1414 | - | } | |
| 1415 | - | if _, err := a.tabCoord.Create(ctx, initialURL); err != nil { | |
| 1787 | + | if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil { | |
| 1416 | 1788 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 1417 | 1789 | } | |
| 1418 | 1790 | } | |
| 1419 | 1791 | ||
| 1792 | + | func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |
| 1793 | + | if a == nil { | |
| 1794 | + | return | |
| 1795 | + | } | |
| 1796 | + | ||
| 1797 | + | tabIDs := make(map[entity.TabID]struct{}) | |
| 1798 | + | if a.tabs != nil { | |
| 1799 | + | for _, tab := range a.tabs.Tabs { | |
| 1800 | + | if tab == nil { | |
| 1801 | + | continue | |
| 1802 | + | } | |
| 1803 | + | tabIDs[tab.ID] = struct{}{} | |
| 1804 | + | } | |
| 1805 | + | } | |
| 1806 | + | for tabID := range a.workspaceViews { | |
| 1807 | + | tabIDs[tabID] = struct{}{} | |
| 1808 | + | } | |
| 1809 | + | for tabID := range a.windowForTab { | |
| 1810 | + | tabIDs[tabID] = struct{}{} | |
| 1811 | + | } | |
| 1812 | + | ||
| 1813 | + | var tabBar *component.TabBar | |
| 1814 | + | if a.mainWindow != nil { | |
| 1815 | + | tabBar = a.mainWindow.TabBar() | |
| 1816 | + | } | |
| 1817 | + | for tabID := range tabIDs { | |
| 1818 | + | if tabBar != nil { | |
| 1819 | + | tabBar.RemoveTab(tabID) | |
| 1820 | + | } | |
| 1821 | + | a.releaseFloatingSessionsForTab(ctx, tabID) | |
| 1822 | + | delete(a.workspaceViews, tabID) | |
| 1823 | + | delete(a.windowForTab, tabID) | |
| 1824 | + | } | |
| 1825 | + | if a.mainWindow != nil { | |
| 1826 | + | a.mainWindow.SetContent(nil) | |
| 1827 | + | } | |
| 1828 | + | } | |
| 1829 | + | ||
| 1420 | 1830 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | |
| 1421 | 1831 | log := logging.FromContext(ctx) | |
| 1422 | 1832 | ||
| @@ -1442,6 +1852,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1442 | 1852 | return fmt.Errorf("failed to build tab list from snapshot") | |
| 1443 | 1853 | } | |
| 1444 | 1854 | ||
| 1855 | + | a.clearSessionRestoreUIState(ctx) | |
| 1856 | + | ||
| 1445 | 1857 | // Replace tabs in-place to preserve references held by TabCoordinator | |
| 1446 | 1858 | a.tabs.ReplaceFrom(restoredTabs) | |
| 1447 | 1859 | ||
| @@ -1450,6 +1862,10 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1450 | 1862 | tabBar := a.mainWindow.TabBar() | |
| 1451 | 1863 | for _, tab := range a.tabs.Tabs { | |
| 1452 | 1864 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 1865 | + | wsView := a.workspaceViews[tab.ID] | |
| 1866 | + | if a.wsCoord != nil && wsView != nil { | |
| 1867 | + | a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView) | |
| 1868 | + | } | |
| 1453 | 1869 | if tabBar != nil { | |
| 1454 | 1870 | tabBar.AddTab(tab) | |
| 1455 | 1871 | } | |
| @@ -1479,6 +1895,7 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||
| 1479 | 1895 | if a.mainWindow != nil { | |
| 1480 | 1896 | a.mainWindow.Show() | |
| 1481 | 1897 | } | |
| 1898 | + | a.startBrowserLaunchRelayListener(ctx) | |
| 1482 | 1899 | log.Info().Msg("main window displayed") | |
| 1483 | 1900 | ||
| 1484 | 1901 | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | |
| @@ -1504,13 +1921,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||
| 1504 | 1921 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | |
| 1505 | 1922 | ||
| 1506 | 1923 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1507 | - | if a.appToaster != nil { | |
| 1508 | - | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |
| 1509 | - | a.appToaster.Show(ctx, msg, component.ToastWarning, | |
| 1510 | - | component.WithDuration(crashReportToastDurationMs), | |
| 1511 | - | component.WithPosition(component.ToastPositionBottomRight), | |
| 1512 | - | ) | |
| 1513 | - | } | |
| 1924 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |
| 1925 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning, | |
| 1926 | + | component.WithDuration(crashReportToastDurationMs), | |
| 1927 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 1928 | + | ) | |
| 1514 | 1929 | return false | |
| 1515 | 1930 | }) | |
| 1516 | 1931 | glib.IdleAdd(&cb, 0) | |
| @@ -1562,6 +1977,9 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 1562 | 1977 | } | |
| 1563 | 1978 | } | |
| 1564 | 1979 | ||
| 1980 | + | // Stop accepting relaunches before teardown starts. | |
| 1981 | + | a.closeBrowserLaunchRelayListener() | |
| 1982 | + | ||
| 1565 | 1983 | // Cancel context to signal all goroutines | |
| 1566 | 1984 | a.cancel(errors.New("application shutdown")) | |
| 1567 | 1985 | ||
| @@ -1665,6 +2083,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1665 | 2083 | a.createWorkspaceView(ctx, tab) | |
| 1666 | 2084 | }) | |
| 1667 | 2085 | a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) { | |
| 2086 | + | if bw := a.browserWindowForTab(tab.ID); bw != nil { | |
| 2087 | + | bw.activeTabID = tab.ID | |
| 2088 | + | a.activateBrowserWindow(bw) | |
| 2089 | + | } | |
| 1668 | 2090 | a.switchWorkspaceView(ctx, tab.ID) | |
| 1669 | 2091 | }) | |
| 1670 | 2092 | a.tabCoord.SetOnQuit(a.Quit) | |
| @@ -1674,27 +2096,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1674 | 2096 | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| 1675 | 2097 | }) | |
| 1676 | 2098 | ||
| 1677 | - | // Wire tab bar click handling to coordinator | |
| 1678 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| 1679 | - | a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | |
| 1680 | - | if err := a.tabCoord.Switch(ctx, tabID); err != nil { | |
| 1681 | - | log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed") | |
| 1682 | - | } | |
| 1683 | - | }) | |
| 2099 | + | for _, bw := range a.browserWindows { | |
| 2100 | + | a.wireBrowserWindowTabBar(ctx, bw) | |
| 1684 | 2101 | } | |
| 1685 | 2102 | ||
| 1686 | 2103 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | |
| 1687 | - | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | |
| 1688 | - | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | |
| 1689 | - | return | |
| 1690 | - | } | |
| 1691 | - | if entering { | |
| 1692 | - | // Hide tab bar when entering fullscreen video | |
| 1693 | - | a.mainWindow.TabBar().SetVisible(false) | |
| 1694 | - | } else { | |
| 1695 | - | // Restore tab bar visibility based on normal logic | |
| 1696 | - | a.tabCoord.UpdateBarVisibility(ctx) | |
| 1697 | - | } | |
| 2104 | + | a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) { | |
| 2105 | + | a.handlePaneFullscreenChanged(paneID, entering) | |
| 1698 | 2106 | }) | |
| 1699 | 2107 | ||
| 1700 | 2108 | // 3. Workspace Coordinator | |
| @@ -1767,8 +2175,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1767 | 2175 | }) | |
| 1768 | 2176 | ||
| 1769 | 2177 | // Wire window title updates when active pane's title changes | |
| 1770 | - | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | |
| 1771 | - | a.updateWindowTitle(title) | |
| 2178 | + | a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) { | |
| 2179 | + | a.handlePaneWindowTitleChanged(paneID, title) | |
| 1772 | 2180 | }) | |
| 1773 | 2181 | ||
| 1774 | 2182 | // Wire pane URI updates for session snapshots (searches all tabs) | |
| @@ -1813,8 +2221,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1813 | 2221 | a.deps.ZoomUC, | |
| 1814 | 2222 | a.deps.CopyURLUC, | |
| 1815 | 2223 | a.deps.Config.Workspace.NewPaneURL, | |
| 2224 | + | a.contentCoord.ActivePaneID, | |
| 1816 | 2225 | ) | |
| 1817 | 2226 | a.wireKeyboardActions() | |
| 2227 | + | for _, bw := range a.browserWindows { | |
| 2228 | + | a.initBrowserWindowInput(ctx, bw) | |
| 2229 | + | } | |
| 1818 | 2230 | ||
| 1819 | 2231 | log.Debug().Msg("coordinators initialized") | |
| 1820 | 2232 | } | |
| @@ -1837,10 +2249,16 @@ func (a *App) wireKeyboardActions() { | |||
| 1837 | 2249 | a.CloseFindBar(ctx) | |
| 1838 | 2250 | return nil | |
| 1839 | 2251 | }) | |
| 1840 | - | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error { | |
| 2252 | + | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2253 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |
| 2254 | + | a.activateBrowserWindow(bw) | |
| 2255 | + | } | |
| 1841 | 2256 | return a.HandleMovePaneToTab(ctx) | |
| 1842 | 2257 | }) | |
| 1843 | - | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | |
| 2258 | + | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2259 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |
| 2260 | + | a.activateBrowserWindow(bw) | |
| 2261 | + | } | |
| 1844 | 2262 | return a.HandleMovePaneToNextTab(ctx) | |
| 1845 | 2263 | }) | |
| 1846 | 2264 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| @@ -1857,33 +2275,73 @@ func (a *App) wireKeyboardActions() { | |||
| 1857 | 2275 | } | |
| 1858 | 2276 | ||
| 1859 | 2277 | func (a *App) wireWebRTCPermissionIndicator() { | |
| 1860 | - | if a.webrtcIndicator == nil || a.contentCoord == nil { | |
| 2278 | + | if a.contentCoord == nil { | |
| 1861 | 2279 | return | |
| 1862 | 2280 | } | |
| 1863 | 2281 | ctx := context.Background() | |
| 1864 | 2282 | if a.deps != nil && a.deps.Ctx != nil { | |
| 1865 | 2283 | ctx = a.deps.Ctx | |
| 1866 | 2284 | } | |
| 1867 | - | log := logging.FromContext(ctx) | |
| 1868 | 2285 | ||
| 1869 | - | a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) { | |
| 1870 | - | a.webrtcIndicator.SetOrigin(origin) | |
| 2286 | + | a.contentCoord.SetOnPermissionActivity(func( | |
| 2287 | + | paneID entity.PaneID, | |
| 2288 | + | origin string, | |
| 2289 | + | permTypes []entity.PermissionType, | |
| 2290 | + | state content.PermissionActivityState, | |
| 2291 | + | ) { | |
| 2292 | + | bw := a.browserWindowForPane(paneID) | |
| 2293 | + | if bw == nil || bw.webrtcIndicator == nil { | |
| 2294 | + | return | |
| 2295 | + | } | |
| 2296 | + | if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil { | |
| 2297 | + | a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog) | |
| 2298 | + | } | |
| 2299 | + | bw.webrtcIndicator.SetOrigin(origin) | |
| 1871 | 2300 | ||
| 1872 | 2301 | switch state { | |
| 1873 | 2302 | case content.PermissionActivityRequesting: | |
| 1874 | - | a.webrtcIndicator.MarkRequesting(permTypes) | |
| 2303 | + | bw.webrtcIndicator.MarkRequesting(permTypes) | |
| 1875 | 2304 | case content.PermissionActivityAllowed: | |
| 1876 | - | a.webrtcIndicator.MarkAllowed(permTypes) | |
| 2305 | + | bw.webrtcIndicator.MarkAllowed(permTypes) | |
| 1877 | 2306 | case content.PermissionActivityBlocked: | |
| 1878 | - | a.webrtcIndicator.MarkBlocked(permTypes) | |
| 2307 | + | bw.webrtcIndicator.MarkBlocked(permTypes) | |
| 1879 | 2308 | } | |
| 1880 | 2309 | ||
| 1881 | 2310 | for _, permType := range permTypes { | |
| 1882 | - | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |
| 2311 | + | a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType) | |
| 2312 | + | } | |
| 2313 | + | }) | |
| 2314 | + | ||
| 2315 | + | // Reset the owning window's indicator when that pane navigates away. | |
| 2316 | + | a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) { | |
| 2317 | + | bw := a.browserWindowForPane(paneID) | |
| 2318 | + | if bw == nil || bw.webrtcIndicator == nil { | |
| 2319 | + | return | |
| 2320 | + | } | |
| 2321 | + | newOrigin, err := urlutil.ExtractOrigin(uri) | |
| 2322 | + | if err != nil { | |
| 2323 | + | bw.webrtcIndicator.Reset() | |
| 2324 | + | return | |
| 2325 | + | } | |
| 2326 | + | ||
| 2327 | + | currentOrigin := bw.webrtcIndicator.Origin() | |
| 2328 | + | if currentOrigin != "" && currentOrigin != newOrigin { | |
| 2329 | + | bw.webrtcIndicator.Reset() | |
| 1883 | 2330 | } | |
| 1884 | 2331 | }) | |
| 2332 | + | } | |
| 2333 | + | ||
| 2334 | + | func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) { | |
| 2335 | + | if bw == nil || bw.webrtcIndicator == nil { | |
| 2336 | + | return | |
| 2337 | + | } | |
| 2338 | + | ctx := context.Background() | |
| 2339 | + | if a.deps != nil && a.deps.Ctx != nil { | |
| 2340 | + | ctx = a.deps.Ctx | |
| 2341 | + | } | |
| 2342 | + | log := logging.FromContext(ctx) | |
| 1885 | 2343 | ||
| 1886 | - | a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) { | |
| 2344 | + | bw.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) { | |
| 1887 | 2345 | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 1888 | 2346 | return | |
| 1889 | 2347 | } | |
| @@ -1908,27 +2366,17 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1908 | 2366 | } | |
| 1909 | 2367 | } | |
| 1910 | 2368 | ||
| 1911 | - | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |
| 1912 | - | }) | |
| 1913 | - | ||
| 1914 | - | // Reset indicator when the active pane navigates away from the current origin. | |
| 1915 | - | a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) { | |
| 1916 | - | newOrigin, err := urlutil.ExtractOrigin(uri) | |
| 1917 | - | if err != nil { | |
| 1918 | - | // Internal pages (dumb://, about:) — clear the indicator. | |
| 1919 | - | a.webrtcIndicator.Reset() | |
| 1920 | - | return | |
| 1921 | - | } | |
| 1922 | - | ||
| 1923 | - | currentOrigin := a.webrtcIndicator.Origin() | |
| 1924 | - | if currentOrigin != "" && currentOrigin != newOrigin { | |
| 1925 | - | a.webrtcIndicator.Reset() | |
| 1926 | - | } | |
| 2369 | + | a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType) | |
| 1927 | 2370 | }) | |
| 1928 | 2371 | } | |
| 1929 | 2372 | ||
| 1930 | - | func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) { | |
| 1931 | - | if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 2373 | + | func (a *App) syncWebRTCPermissionLockState( | |
| 2374 | + | ctx context.Context, | |
| 2375 | + | indicator *component.WebRTCPermissionIndicator, | |
| 2376 | + | origin string, | |
| 2377 | + | permType entity.PermissionType, | |
| 2378 | + | ) { | |
| 2379 | + | if indicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 1932 | 2380 | return | |
| 1933 | 2381 | } | |
| 1934 | 2382 | ||
| @@ -1943,11 +2391,40 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, | |||
| 1943 | 2391 | } | |
| 1944 | 2392 | ||
| 1945 | 2393 | if record == nil { | |
| 1946 | - | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |
| 2394 | + | indicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |
| 1947 | 2395 | return | |
| 1948 | 2396 | } | |
| 1949 | 2397 | ||
| 1950 | - | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | |
| 2398 | + | indicator.SetStoredDecision(permType, record.Decision, true) | |
| 2399 | + | } | |
| 2400 | + | ||
| 2401 | + | // generateWindowID generates a unique ID for top-level browser windows. | |
| 2402 | + | func (a *App) generateWindowID() string { | |
| 2403 | + | a.windowIDMu.Lock() | |
| 2404 | + | defer a.windowIDMu.Unlock() | |
| 2405 | + | a.windowIDCounter++ | |
| 2406 | + | return fmt.Sprintf("w%d", a.windowIDCounter) | |
| 2407 | + | } | |
| 2408 | + | ||
| 2409 | + | // runOnMainThread executes fn inline when already on the GTK main context; | |
| 2410 | + | // otherwise it dispatches fn to the GTK main loop and waits for completion. | |
| 2411 | + | func (a *App) runOnMainThread(fn func()) { | |
| 2412 | + | if fn == nil { | |
| 2413 | + | return | |
| 2414 | + | } | |
| 2415 | + | glibCtx := glib.MainContextDefault() | |
| 2416 | + | if glibCtx != nil && glibCtx.IsOwner() { | |
| 2417 | + | fn() | |
| 2418 | + | return | |
| 2419 | + | } | |
| 2420 | + | ||
| 2421 | + | done := make(chan struct{}) | |
| 2422 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |
| 2423 | + | fn() | |
| 2424 | + | close(done) | |
| 2425 | + | }) | |
| 2426 | + | glib.IdleAddOnce(&cb, 0) | |
| 2427 | + | <-done | |
| 1951 | 2428 | } | |
| 1952 | 2429 | ||
| 1953 | 2430 | // generateID generates a unique ID for tabs and panes. | |
| @@ -1989,27 +2466,39 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int { | |||
| 1989 | 2466 | ||
| 1990 | 2467 | // updateWindowTitle updates the window title with the given page title. | |
| 1991 | 2468 | // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty. | |
| 1992 | - | func (a *App) updateWindowTitle(pageTitle string) { | |
| 1993 | - | if a.mainWindow == nil { | |
| 2469 | + | func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |
| 2470 | + | if target == nil { | |
| 2471 | + | target = a.browserWindowForMainWindow(a.mainWindow) | |
| 2472 | + | } | |
| 2473 | + | if target == nil || target.mainWindow == nil { | |
| 1994 | 2474 | return | |
| 1995 | 2475 | } | |
| 1996 | 2476 | ||
| 1997 | - | title := "Dumber" | |
| 2477 | + | title := appTitle | |
| 1998 | 2478 | if pageTitle != "" { | |
| 1999 | - | title = pageTitle + " - Dumber" | |
| 2479 | + | title = pageTitle + " - " + appTitle | |
| 2000 | 2480 | } | |
| 2001 | - | a.mainWindow.SetTitle(title) | |
| 2481 | + | target.mainWindow.SetTitle(title) | |
| 2002 | 2482 | } | |
| 2003 | 2483 | ||
| 2004 | 2484 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |
| 2005 | - | func (a *App) updateWindowTitleFromActivePane() { | |
| 2006 | - | ws := a.activeWorkspace() | |
| 2485 | + | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |
| 2486 | + | var ws *entity.Workspace | |
| 2487 | + | if a.tabs != nil { | |
| 2488 | + | if tab := a.tabs.Find(tabID); tab != nil { | |
| 2489 | + | ws = tab.Workspace | |
| 2490 | + | } else { | |
| 2491 | + | ws = a.activeWorkspace() | |
| 2492 | + | } | |
| 2493 | + | } else { | |
| 2494 | + | ws = a.activeWorkspace() | |
| 2495 | + | } | |
| 2007 | 2496 | if ws == nil || a.contentCoord == nil { | |
| 2008 | - | a.updateWindowTitle("") | |
| 2497 | + | a.updateWindowTitle("", a.browserWindowForTab(tabID)) | |
| 2009 | 2498 | return | |
| 2010 | 2499 | } | |
| 2011 | 2500 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | |
| 2012 | - | a.updateWindowTitle(title) | |
| 2501 | + | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | |
| 2013 | 2502 | } | |
| 2014 | 2503 | ||
| 2015 | 2504 | // handleModeChange is called when the input mode changes. | |
| @@ -2026,8 +2515,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2026 | 2515 | ||
| 2027 | 2516 | // Update global border overlay visibility based on mode. | |
| 2028 | 2517 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | |
| 2029 | - | if a.borderMgr != nil { | |
| 2030 | - | a.borderMgr.OnModeChange(ctx, from, to) | |
| 2518 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil { | |
| 2519 | + | bw.borderMgr.OnModeChange(ctx, from, to) | |
| 2031 | 2520 | } | |
| 2032 | 2521 | ||
| 2033 | 2522 | // Show/hide mode indicator toaster based on config. | |
| @@ -2036,25 +2525,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2036 | 2525 | ||
| 2037 | 2526 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |
| 2038 | 2527 | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |
| 2039 | - | if a.modeToaster == nil { | |
| 2528 | + | bw := a.lastFocusedBrowserWindow() | |
| 2529 | + | if bw == nil || bw.modeToaster == nil { | |
| 2040 | 2530 | return | |
| 2041 | 2531 | } | |
| 2042 | 2532 | ||
| 2043 | 2533 | // Check if mode indicator toaster is enabled in config. | |
| 2044 | 2534 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 2045 | - | a.modeToaster.Hide() | |
| 2535 | + | bw.modeToaster.Hide() | |
| 2046 | 2536 | return | |
| 2047 | 2537 | } | |
| 2048 | 2538 | ||
| 2049 | 2539 | if mode == input.ModeNormal { | |
| 2050 | - | a.modeToaster.Hide() | |
| 2540 | + | bw.modeToaster.Hide() | |
| 2051 | 2541 | return | |
| 2052 | 2542 | } | |
| 2053 | 2543 | ||
| 2054 | 2544 | // Show persistent toaster at bottom-left with mode display name. | |
| 2055 | 2545 | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 2056 | 2546 | modeClass := getModeToastClass(mode) | |
| 2057 | - | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2547 | + | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2058 | 2548 | component.WithDuration(0), // Persistent until mode exits. | |
| 2059 | 2549 | component.WithPosition(component.ToastPositionBottomLeft), | |
| 2060 | 2550 | component.WithModeClass(modeClass), | |
| @@ -2122,14 +2612,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) { | |||
| 2122 | 2612 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 2123 | 2613 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 2124 | 2614 | ||
| 2615 | + | target := a.browserWindowForTab(tab.ID) | |
| 2616 | + | if target != nil { | |
| 2617 | + | a.setBrowserWindowForTab(tab.ID, target) | |
| 2618 | + | } | |
| 2619 | + | ||
| 2125 | 2620 | // Attach to content area | |
| 2126 | 2621 | wsView := a.workspaceViews[tab.ID] | |
| 2127 | - | if wsView != nil && a.mainWindow != nil { | |
| 2622 | + | if wsView != nil && target != nil && target.mainWindow != nil { | |
| 2128 | 2623 | widget := wsView.Widget() | |
| 2129 | 2624 | if widget != nil { | |
| 2130 | 2625 | gtkWidget := widget.GtkWidget() | |
| 2131 | 2626 | if gtkWidget != nil { | |
| 2132 | - | a.mainWindow.SetContent(gtkWidget) | |
| 2627 | + | target.mainWindow.SetContent(gtkWidget) | |
| 2133 | 2628 | } | |
| 2134 | 2629 | } | |
| 2135 | 2630 | } | |
| @@ -2202,6 +2697,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2202 | 2697 | ||
| 2203 | 2698 | // activeWorkspace returns the workspace of the active tab. | |
| 2204 | 2699 | func (a *App) activeWorkspace() *entity.Workspace { | |
| 2700 | + | if a.tabs == nil { | |
| 2701 | + | return nil | |
| 2702 | + | } | |
| 2205 | 2703 | activeTab := a.tabs.ActiveTab() | |
| 2206 | 2704 | if activeTab == nil { | |
| 2207 | 2705 | return nil | |
| @@ -2308,12 +2806,15 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 2308 | 2806 | } | |
| 2309 | 2807 | ||
| 2310 | 2808 | // Swap content (MainWindow.SetContent now properly removes old content) | |
| 2311 | - | if a.mainWindow != nil { | |
| 2809 | + | if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil { | |
| 2810 | + | target.activeTabID = tabID | |
| 2811 | + | target.mainWindow.SetContent(gtkWidget) | |
| 2812 | + | } else if a.mainWindow != nil { | |
| 2312 | 2813 | a.mainWindow.SetContent(gtkWidget) | |
| 2313 | 2814 | } | |
| 2314 | 2815 | ||
| 2315 | 2816 | // Update window title with the new active pane's title | |
| 2316 | - | a.updateWindowTitleFromActivePane() | |
| 2817 | + | a.updateWindowTitleFromActivePane(tabID) | |
| 2317 | 2818 | a.syncFloatingFocus() | |
| 2318 | 2819 | ||
| 2319 | 2820 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | |
| @@ -2494,8 +2995,8 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve | |||
| 2494 | 2995 | return | |
| 2495 | 2996 | } | |
| 2496 | 2997 | ||
| 2497 | - | cb := func(_ gtk.Overlay, widget *gtk.Widget, allocationPtr *uintptr) bool { | |
| 2498 | - | var widgetPtr uintptr | |
| 2998 | + | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool { | |
| 2999 | + | widget := gtk.WidgetNewFromInternalPtr(widgetPtr) | |
| 2499 | 3000 | if widget != nil { | |
| 2500 | 3001 | widgetPtr = widget.GoPointer() | |
| 2501 | 3002 | } | |
| @@ -2755,9 +3256,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||
| 2755 | 3256 | if session.omnibox == nil { | |
| 2756 | 3257 | cfg := a.omniboxCfg | |
| 2757 | 3258 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 2758 | - | if a.appToaster != nil { | |
| 2759 | - | a.appToaster.Show(toastCtx, message, level) | |
| 2760 | - | } | |
| 3259 | + | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) | |
| 2761 | 3260 | } | |
| 2762 | 3261 | ||
| 2763 | 3262 | omnibox := component.NewOmnibox(ctx, cfg) | |
| @@ -3183,13 +3682,16 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 3183 | 3682 | a.deps.OnConfigChange(func() { | |
| 3184 | 3683 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 3185 | 3684 | a.applyAppearanceConfig(ctx) | |
| 3186 | - | // Reload keyboard shortcuts | |
| 3187 | - | if a.keyboardHandler != nil { | |
| 3188 | - | a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3189 | - | } | |
| 3190 | - | // Reload global shortcuts (removes stale, re-registers from config) | |
| 3191 | - | if a.globalShortcutHandler != nil { | |
| 3192 | - | a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3685 | + | for _, bw := range a.browserWindows { | |
| 3686 | + | if bw == nil { | |
| 3687 | + | continue | |
| 3688 | + | } | |
| 3689 | + | if bw.keyboardHandler != nil { | |
| 3690 | + | bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3691 | + | } | |
| 3692 | + | if bw.globalShortcutHandler != nil { | |
| 3693 | + | bw.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3694 | + | } | |
| 3193 | 3695 | } | |
| 3194 | 3696 | return false | |
| 3195 | 3697 | }) | |
| @@ -3278,21 +3780,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) { | |||
| 3278 | 3780 | ||
| 3279 | 3781 | switch status.State { | |
| 3280 | 3782 | case port.FilterStateLoading: | |
| 3281 | - | if a.appToaster != nil { | |
| 3282 | - | a.appToaster.Show(ctx, status.Message, component.ToastInfo) | |
| 3283 | - | } | |
| 3783 | + | a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo) | |
| 3284 | 3784 | case port.FilterStateActive: | |
| 3285 | 3785 | // Apply filters to existing webviews that were created before filters loaded | |
| 3286 | 3786 | if a.contentCoord != nil && a.deps.FilterManager != nil { | |
| 3287 | 3787 | a.contentCoord.ApplyFiltersToAll(ctx) | |
| 3288 | 3788 | log.Debug().Msg("applied filters to all existing webviews") | |
| 3289 | 3789 | } | |
| 3290 | - | if a.appToaster != nil { | |
| 3291 | - | a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |
| 3292 | - | } | |
| 3790 | + | a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |
| 3293 | 3791 | case port.FilterStateError: | |
| 3294 | - | if a.appToaster != nil { | |
| 3295 | - | a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError) | |
| 3296 | - | } | |
| 3792 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError) | |
| 3297 | 3793 | } | |
| 3298 | 3794 | } | |
A internal/ui/app_browser_launch_test.go +1087 -0 collapsed to keep the page light
A internal/ui/app_clipboard_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | |
| 16 | + | ctx := context.Background() | |
| 17 | + | engine := portmocks.NewMockEngine(t) | |
| 18 | + | var registerDeps port.HandlerDependencies | |
| 19 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |
| 20 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |
| 21 | + | registerDeps = deps | |
| 22 | + | }). | |
| 23 | + | Return(nil). | |
| 24 | + | Once() | |
| 25 | + | ||
| 26 | + | deps := &Dependencies{ | |
| 27 | + | Ctx: ctx, | |
| 28 | + | Config: &config.Config{}, | |
| 29 | + | Engine: engine, | |
| 30 | + | HandlerDeps: port.HandlerDeps{ | |
| 31 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |
| 32 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 33 | + | }, | |
| 34 | + | Clipboard: nil, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | app, err := New(deps) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.NotNil(t, app) | |
| 40 | + | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |
| 44 | + | ctx := context.Background() | |
| 45 | + | engine := portmocks.NewMockEngine(t) | |
| 46 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 47 | + | var registerDeps port.HandlerDependencies | |
| 48 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |
| 49 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |
| 50 | + | registerDeps = deps | |
| 51 | + | }). | |
| 52 | + | Return(nil). | |
| 53 | + | Once() | |
| 54 | + | ||
| 55 | + | deps := &Dependencies{ | |
| 56 | + | Ctx: ctx, | |
| 57 | + | Config: &config.Config{}, | |
| 58 | + | Engine: engine, | |
| 59 | + | HandlerDeps: port.HandlerDeps{ | |
| 60 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |
| 61 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 62 | + | }, | |
| 63 | + | Clipboard: clipboard, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | app, err := New(deps) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.NotNil(t, app) | |
| 69 | + | require.NotNil(t, registerDeps.ClipboardTextOrchestrator) | |
| 70 | + | } | |
A internal/ui/app_download_test.go +86 -0 collapsed to keep the page light
A internal/ui/app_omnibox_config_test.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | configpkg "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestBuildOmniboxConfig_UsesConfiguredMostVisitedDays(t *testing.T) { | |
| 10 | + | deps := &Dependencies{ | |
| 11 | + | Config: &configpkg.Config{ | |
| 12 | + | Omnibox: configpkg.OmniboxConfig{ | |
| 13 | + | MostVisitedDays: 7, | |
| 14 | + | }, | |
| 15 | + | }, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | got := buildOmniboxConfig(deps, nil, omniboxCallbacks{}) | |
| 19 | + | if got.MostVisitedDays != 7 { | |
| 20 | + | t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays) | |
| 21 | + | } | |
| 22 | + | } | |
A internal/ui/browser_window.go +321 -0 collapsed to keep the page light
A internal/ui/browser_window_test.go +311 -0 collapsed to keep the page light
M internal/ui/component/omnibox.go +27 -5
| @@ -115,6 +115,7 @@ type Omnibox struct { | |||
| 115 | 115 | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 116 | 116 | defaultSearch string | |
| 117 | 117 | initialBehavior entity.OmniboxInitialBehavior | |
| 118 | + | mostVisitedDays int | |
| 118 | 119 | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | |
| 119 | 120 | ctx context.Context | |
| 120 | 121 | ||
| @@ -161,6 +162,7 @@ type OmniboxConfig struct { | |||
| 161 | 162 | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 162 | 163 | DefaultSearch string | |
| 163 | 164 | InitialBehavior entity.OmniboxInitialBehavior | |
| 165 | + | MostVisitedDays int | |
| 164 | 166 | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | |
| 165 | 167 | UIScale float64 // UI scale for favicon sizing | |
| 166 | 168 | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| @@ -194,6 +196,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 194 | 196 | shortcutsUC: cfg.ShortcutsUC, | |
| 195 | 197 | defaultSearch: cfg.DefaultSearch, | |
| 196 | 198 | initialBehavior: cfg.InitialBehavior, | |
| 199 | + | mostVisitedDays: cfg.MostVisitedDays, | |
| 197 | 200 | saveInitialBehaviorFn: cfg.SaveInitialBehavior, | |
| 198 | 201 | onToast: cfg.OnToast, | |
| 199 | 202 | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| @@ -725,8 +728,9 @@ func (o *Omnibox) initList() error { | |||
| 725 | 728 | o.listBox.AddCssClass("omnibox-listbox") | |
| 726 | 729 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 727 | 730 | ||
| 728 | - | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 731 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 729 | 732 | o.restoreEntryToRealInput() | |
| 733 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 730 | 734 | if row == nil { | |
| 731 | 735 | o.mu.Lock() | |
| 732 | 736 | o.selectedIndex = -1 | |
| @@ -742,7 +746,8 @@ func (o *Omnibox) initList() error { | |||
| 742 | 746 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 743 | 747 | ||
| 744 | 748 | // Handle row activation (click or Enter) - navigate directly to the URL | |
| 745 | - | rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 749 | + | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 750 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 746 | 751 | if row == nil { | |
| 747 | 752 | return | |
| 748 | 753 | } | |
| @@ -750,12 +755,17 @@ func (o *Omnibox) initList() error { | |||
| 750 | 755 | o.mu.RLock() | |
| 751 | 756 | mode := o.viewMode | |
| 752 | 757 | bangMode := o.bangMode | |
| 758 | + | bangSuggestions := o.bangSuggestions | |
| 753 | 759 | suggestions := o.suggestions | |
| 754 | 760 | favorites := o.favorites | |
| 755 | 761 | o.mu.RUnlock() | |
| 756 | 762 | ||
| 757 | 763 | // Don't navigate in bang mode - activating a bang should fill it in | |
| 758 | 764 | if bangMode { | |
| 765 | + | if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil { | |
| 766 | + | o.entry.SetText(bangText) | |
| 767 | + | o.entry.SetPosition(-1) | |
| 768 | + | } | |
| 759 | 769 | return | |
| 760 | 770 | } | |
| 761 | 771 | ||
| @@ -1453,6 +1463,13 @@ func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggest | |||
| 1453 | 1463 | return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true | |
| 1454 | 1464 | } | |
| 1455 | 1465 | ||
| 1466 | + | func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) { | |
| 1467 | + | if idx < 0 || idx >= len(bangSuggestions) { | |
| 1468 | + | return "", false | |
| 1469 | + | } | |
| 1470 | + | return "!" + bangSuggestions[idx].Key + " ", true | |
| 1471 | + | } | |
| 1472 | + | ||
| 1456 | 1473 | func visibleResultCount(total, maxVisible int) int { | |
| 1457 | 1474 | if total <= 0 { | |
| 1458 | 1475 | return 0 | |
| @@ -1590,6 +1607,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1590 | 1607 | // Capture effective result limit on the GTK main thread before spawning goroutine | |
| 1591 | 1608 | initialLimit := o.effectiveMaxRows() | |
| 1592 | 1609 | initialBehavior := o.initialBehavior | |
| 1610 | + | mostVisitedDays := o.mostVisitedDays | |
| 1593 | 1611 | ||
| 1594 | 1612 | go func() { | |
| 1595 | 1613 | ctx := o.ctx | |
| @@ -1616,7 +1634,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1616 | 1634 | err error | |
| 1617 | 1635 | ) | |
| 1618 | 1636 | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { | |
| 1619 | - | results, err = o.historyUC.GetMostVisited(ctx, 0) | |
| 1637 | + | if mostVisitedDays == 0 { | |
| 1638 | + | results, err = o.historyUC.GetMostVisited(ctx, 0) | |
| 1639 | + | } else { | |
| 1640 | + | results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays) | |
| 1641 | + | } | |
| 1620 | 1642 | if err == nil && len(results) > initialLimit { | |
| 1621 | 1643 | results = results[:initialLimit] | |
| 1622 | 1644 | } | |
| @@ -2283,8 +2305,8 @@ func (o *Omnibox) navigateToSelected() { | |||
| 2283 | 2305 | } | |
| 2284 | 2306 | ||
| 2285 | 2307 | // Otherwise, Enter autocompletes the selected bang. | |
| 2286 | - | if idx >= 0 && idx < len(bangSuggestions) { | |
| 2287 | - | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | |
| 2308 | + | if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok { | |
| 2309 | + | o.entry.SetText(bangText) | |
| 2288 | 2310 | o.entry.SetPosition(-1) | |
| 2289 | 2311 | return | |
| 2290 | 2312 | } | |
M internal/ui/component/omnibox_initial_behavior_test.go +78 -0 collapsed to keep the page light
M internal/ui/component/omnibox_navigation_state_test.go +11 -0
| @@ -199,6 +199,17 @@ func TestSelectedTargetURL(t *testing.T) { | |||
| 199 | 199 | } | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | + | func TestBangSuggestionTextAt(t *testing.T) { | |
| 203 | + | bangSuggestions := []BangSuggestion{{Key: "g"}, {Key: "gh"}} | |
| 204 | + | ||
| 205 | + | if got, ok := bangSuggestionTextAt(1, bangSuggestions); !ok || got != "!gh " { | |
| 206 | + | t.Fatalf("bangSuggestionTextAt(1) = (%q, %v), want (%q, %v)", got, ok, "!gh ", true) | |
| 207 | + | } | |
| 208 | + | if got, ok := bangSuggestionTextAt(99, bangSuggestions); ok || got != "" { | |
| 209 | + | t.Fatalf("bangSuggestionTextAt(out of range) = (%q, %v), want (%q, %v)", got, ok, "", false) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 202 | 213 | func TestVisibleGhostSuggestion(t *testing.T) { | |
| 203 | 214 | suggestions := []Suggestion{ | |
| 204 | 215 | {URL: "https://github.com/bnema/dumber"}, | |
M internal/ui/component/pane_view.go +23 -7 collapsed to keep the page light
M internal/ui/component/pane_view_test.go +10 -0 collapsed to keep the page light
M internal/ui/component/progress_bar.go +116 -16 collapsed to keep the page light
A internal/ui/component/progress_bar_test.go +62 -0 collapsed to keep the page light
M internal/ui/component/session_manager.go +2 -1 collapsed to keep the page light
M internal/ui/component/tab_button.go +3 -0 collapsed to keep the page light
M internal/ui/component/tab_picker.go +2 -1 collapsed to keep the page light
M internal/ui/config_migration.go +2 -2 collapsed to keep the page light
M internal/ui/coordinator/content/callbacks.go +5 -4 collapsed to keep the page light
M internal/ui/coordinator/content/coordinator.go +83 -40 collapsed to keep the page light
M internal/ui/coordinator/content/lifecycle.go +15 -1 collapsed to keep the page light
M internal/ui/coordinator/content/lifecycle_test.go +3 -0 collapsed to keep the page light
M internal/ui/coordinator/content/navigation.go +2 -2 collapsed to keep the page light
M internal/ui/coordinator/content/oauth.go +163 -53 collapsed to keep the page light
M internal/ui/coordinator/content/oauth_test.go +181 -24 collapsed to keep the page light
M internal/ui/coordinator/content/popup.go +8 -469 collapsed to keep the page light
A internal/ui/coordinator/content/popup_manager.go +829 -0 collapsed to keep the page light
M internal/ui/coordinator/content/popup_test.go +400 -54 collapsed to keep the page light
M internal/ui/coordinator/content/title.go +19 -25 collapsed to keep the page light
M internal/ui/coordinator/content/title_test.go +57 -0 collapsed to keep the page light
M internal/ui/coordinator/tab.go +6 -1 collapsed to keep the page light
M internal/ui/coordinator/update.go +9 -5 collapsed to keep the page light
M internal/ui/coordinator/workspace.go +38 -3 collapsed to keep the page light
M internal/ui/deps.go +2 -0
| @@ -104,6 +104,8 @@ type Dependencies struct { | |||
| 104 | 104 | // LaunchBrowserURL opens a URL in a new dumber browser window. | |
| 105 | 105 | // Optional: if nil, standalone omnibox web navigations are logged only. | |
| 106 | 106 | LaunchBrowserURL func(uri string) | |
| 107 | + | // BrowserLaunchRelay listens for in-process browser launch requests. | |
| 108 | + | BrowserLaunchRelay port.BrowserLaunchRelay | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | 111 | // Validate checks that all required dependencies are set. | |