detached d84415e

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
commit
d84415e587a00b23ce42f892a679390e46f12d84
author
brice · · 2026-04-24 01:44 UTC
merge of
65a4ec6 + 716a5ee · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
202 files · +22532 -2558
M .github/workflows/aur.yml +2 -2
@@ -98,7 +98,7 @@ jobs:
9898 cat pkgbuild/dumber-browser-git/PKGBUILD
9999
100100 - 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
102102 with:
103103 pkgname: dumber-browser-git
104104 pkgbuild: ./pkgbuild/dumber-browser-git/PKGBUILD
@@ -216,7 +216,7 @@ jobs:
216216 cat pkgbuild/dumber-browser-bin/PKGBUILD
217217
218218 - 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
220220 with:
221221 pkgname: dumber-browser-bin
222222 pkgbuild: ./pkgbuild/dumber-browser-bin/PKGBUILD
M .github/workflows/ci.yml +27 -16
@@ -12,25 +12,34 @@ on:
1212 - 'sqlc.yaml'
1313 - 'assets/**'
1414 - '.github/workflows/**'
15- - '!**.md'
16- - '!docs/**'
17- - '!LICENSE'
1815 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'
3116
3217 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+
3340 lint:
41+ needs: changes
42+ if: needs.changes.outputs.ci == 'true'
3443 runs-on: ubuntu-latest
3544 container:
3645 image: archlinux:latest
@@ -58,13 +67,15 @@ jobs:
5867 run: make build-systemviews
5968 - name: Install golangci-lint
6069 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
6271 - name: Run golangci-lint
6372 run: |
6473 git config --global --add safe.directory "$GITHUB_WORKSPACE"
6574 golangci-lint run --timeout=20m
6675
6776 test:
77+ needs: changes
78+ if: needs.changes.outputs.ci == 'true'
6879 runs-on: ubuntu-latest
6980 container:
7081 image: archlinux:latest
M .github/workflows/dependabot-auto-merge.yml +1 -1
@@ -13,7 +13,7 @@ jobs:
1313 steps:
1414 - name: Dependabot metadata
1515 id: metadata
16- uses: dependabot/fetch-metadata@v2
16+ uses: dependabot/fetch-metadata@v3
1717 with:
1818 github-token: "${{ secrets.GITHUB_TOKEN }}"
1919
M .github/workflows/flatpak.yml +1 -1
@@ -116,7 +116,7 @@ jobs:
116116 cache-key: flatpak-builder-${{ steps.version.outputs.version }}
117117
118118 - name: Upload Flatpak to Release
119- uses: softprops/action-gh-release@v2
119+ uses: softprops/action-gh-release@v3
120120 with:
121121 tag_name: ${{ steps.version.outputs.version }}
122122 files: dumber.flatpak
M .gitignore +0 -1
@@ -8,7 +8,6 @@ dist/
88
99 # Binaries for programs and plugins
1010 /dumber
11-/cef-helper
1211 dumber.exe
1312
1413 # Test binary, built with `go test -c`
M .mockery.yaml +11 -1
@@ -42,13 +42,18 @@ packages:
4242 FilterApplier: {}
4343 FaviconDatabase: {}
4444 IdleInhibitor: {}
45- PopupCapable: {}
45+ PopupLifecycleCapable: {}
46+ PopupOpenerCapable: {}
4647 OAuthCallbackCapable: {}
4748 DevToolsOpener: {}
4849 Printer: {}
4950 AccentKeyHandler: {}
5051 AutoCopyConfig: {}
5152 Clipboard: {}
53+ ClipboardTextOrchestrator: {}
54+ ContextMenuBuilder: {}
55+ ContextMenuActionExecutor: {}
56+ ContextMenuActionExecutorFactory: {}
5257 DesktopIntegration: {}
5358 DownloadEventHandler: {}
5459 DownloadPreparer: {}
@@ -71,6 +76,11 @@ packages:
7176 RuntimeVersionProbe: {}
7277 ScriptRefresher: {}
7378 SnapshotService: {}
79+ BrowserWindowOpener: {}
80+ BrowserLaunchRelay: {}
81+ ImageDataResolver: {}
82+ ResolvedImageSaver: {}
83+ MenuActionDelegator: {}
7484 TextInputTargetProvider: {}
7585 Texture: {}
7686 ToolkitAvailabilityNotifier: {}
M CHANGELOG.md +18 -0
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
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+
725 ## [0.28.0] - 2026-03-27
826
927 ### Added
M Makefile +17 -3
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
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
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -12,6 +12,7 @@ endif
1212 BINARY_NAME=dumber
1313 MAIN_PATH=./cmd/dumber
1414 DIST_DIR=dist
15+LOCAL_BIN_DIR?=$(HOME)/.local/bin
1516
1617 # Detect number of CPU cores for parallel compilation
1718 NPROCS?=$(shell nproc 2>/dev/null || echo 1)
@@ -42,7 +43,7 @@ build: build-systemviews ## Build the application (pure Go, no CGO)
4243 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4344 @mkdir -p $(DIST_DIR)
4445 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45- CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
46+ @rm -f $(DIST_DIR)/cef-helper
4647 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4748
4849 build-systemviews: ## Build the WASM systemviews runtime
@@ -56,9 +57,22 @@ build-quick: ## Build quickly for backend development
5657 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
5758 @mkdir -p $(DIST_DIR)
5859 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
6061 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
6162
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+
6276 # Development targets
6377 dev: ## Run in development mode
6478 @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 (
1818 "github.com/bnema/dumber/internal/domain/entity"
1919 "github.com/bnema/dumber/internal/domain/repository"
2020 "github.com/bnema/dumber/internal/infrastructure/cache"
21+ infracef "github.com/bnema/dumber/internal/infrastructure/cef"
2122 "github.com/bnema/dumber/internal/infrastructure/clipboard"
2223 "github.com/bnema/dumber/internal/infrastructure/config"
2324 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -26,6 +27,7 @@ import (
2627 "github.com/bnema/dumber/internal/infrastructure/filesystem"
2728 "github.com/bnema/dumber/internal/infrastructure/idle"
2829 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
30+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2931 "github.com/bnema/dumber/internal/infrastructure/snapshot"
3032 "github.com/bnema/dumber/internal/infrastructure/textinput"
3133 "github.com/bnema/dumber/internal/infrastructure/updater"
@@ -53,6 +55,9 @@ var initialURL string
5355 // restoreSessionID holds the session ID to restore on startup.
5456 var restoreSessionID string
5557
58+// browserLaunchRelay is shared for early browse handoff and in-app browser launches.
59+var browserLaunchRelay port.BrowserLaunchRelay
60+
5661 type launchMode string
5762
5863 const (
@@ -86,27 +91,78 @@ func launchModeFromArgs(args []string) (launchMode, string) {
8691 return launchModeCLI, ""
8792 }
8893
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+}
91109
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
93122 // --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]
101142 }
102143
144+ enableCrashForensics()
145+
103146 mode, browseURL := launchModeFromArgs(os.Args)
104147 // Run GUI mode for browse command
105148 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+
106162 initialURL = browseURL
107163 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
108164 os.Args = os.Args[:1]
109- os.Exit(runGUI())
165+ os.Exit(runGUI(cfg))
110166 return
111167 }
112168
@@ -127,14 +183,17 @@ func main() {
127183 cmd.Execute()
128184 }
129185
130-func runGUI() int {
186+func runGUI(cfg *config.Config) int {
131187 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
132188
133189 runtime.LockOSThread()
134190 component.SetSkeletonVersion(version)
135191 timer := bootstrap.NewStartupTimer()
136192
137- cfg := initConfig()
193+ if cfg == nil {
194+ cfg = initConfig()
195+ configureBrowserLaunchRelay(cfg)
196+ }
138197 timer.Mark("config")
139198
140199 ctx := initStartupContextWithTrace(cfg)
@@ -187,6 +246,13 @@ func runGUI() int {
187246 log.Error().Err(err).Msg("failed to create application")
188247 return 1
189248 }
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+ }
190256 timer.Mark("ui_deps")
191257 timer.Log(ctx)
192258
@@ -203,6 +269,7 @@ func runStandaloneOmnibox() int {
203269 component.SetSkeletonVersion(version)
204270
205271 cfg := initConfig()
272+ configureBrowserLaunchRelay(cfg)
206273 ctx := initStartupContextWithTrace(cfg)
207274
208275 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -212,9 +279,15 @@ func runStandaloneOmnibox() int {
212279
213280 preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized)
214281
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()
216289 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")
218291 return 1
219292 }
220293 if dbCleanup != nil {
@@ -225,10 +298,11 @@ func runStandaloneOmnibox() int {
225298 uiDeps, err := buildUIDependencies(
226299 ctx,
227300 cfg,
301+ initResult.RuntimeProfile,
228302 initResult.ThemeManager,
229303 initResult.ColorResolver,
230304 initResult.AdwaitaDetector,
231- engine,
305+ nil,
232306 repos,
233307 useCases,
234308 nil,
@@ -239,7 +313,7 @@ func runStandaloneOmnibox() int {
239313 logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies")
240314 return 1
241315 }
242- runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
316+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, nil)
243317
244318 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
245319 }
@@ -356,7 +430,7 @@ func buildAndConfigureApp(
356430 browserSession *bootstrap.BrowserSession,
357431 ) (*ui.App, error) {
358432 uiDeps, err := buildUIDependencies(
359- ctx, cfg, initResult.ThemeManager,
433+ ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,
360434 initResult.ColorResolver, initResult.AdwaitaDetector,
361435 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
362436 )
@@ -392,12 +466,11 @@ func initStackAndRepos(
392466 if needsEagerDB {
393467 // Parallel phase 2: Database + engine initialize concurrently
394468 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,
401474 })
402475 if err != nil {
403476 return nil, nil, nil, err
@@ -407,24 +480,31 @@ func initStackAndRepos(
407480
408481 log := logging.FromContext(ctx)
409482 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,
417489 })
418490 if err != nil {
419491 return nil, nil, nil, err
420492 }
421493
422- lazyDB, err := bootstrap.CreateLazyDatabase()
494+ repos, dbCleanup, err := initStandaloneOmniboxRepos()
423495 if err != nil {
424496 return nil, nil, nil, err
425497 }
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+ }
426506 dbCleanup := func() { _ = lazyDB.Close() }
427- return engine, createLazyRepositories(lazyDB), dbCleanup, nil
507+ return createLazyRepositories(lazyDB), dbCleanup, nil
428508 }
429509
430510 func configureDeferredInit(
@@ -660,20 +740,29 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
660740 }
661741 }
662742
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, "-")
670754 }
671- return false
755+
756+ return args[1] == "--type" &&
757+ len(args) > 2 &&
758+ args[2] != "" &&
759+ !strings.HasPrefix(args[2], "-")
672760 }
673761
674762 func buildUIDependencies(
675763 ctx context.Context,
676764 cfg *config.Config,
765+ runtimeProfile runtimeprofile.Profile,
677766 themeManager *theme.Manager,
678767 colorResolver port.ColorSchemeResolver,
679768 adwaitaDetector port.ToolkitAvailabilityNotifier,
@@ -695,6 +784,7 @@ func buildUIDependencies(
695784 }
696785
697786 focusProvider := textinput.NewFocusProvider()
787+ browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
698788
699789 uiDeps := &ui.Dependencies{
700790 Ctx: ctx,
@@ -705,23 +795,26 @@ func buildUIDependencies(
705795 Theme: themeManager,
706796 ColorResolver: colorResolver,
707797 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,
725818 FaviconAdapterConfig: adapter.FaviconAdapterConfig{
726819 IsInternalURL: favicon.IsInternalURL,
727820 InternalDomain: favicon.InternalDomain,
@@ -738,7 +831,7 @@ func buildUIDependencies(
738831 },
739832 CheckUpdateUC: uc.checkUpdate,
740833 ApplyUpdateUC: uc.applyUpdate,
741- SessionSpawner: desktop.NewSessionSpawner(ctx),
834+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
742835 FileSystem: filesystem.New(),
743836 AccentFocusProvider: focusProvider,
744837 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
@@ -759,9 +852,12 @@ func buildUIDependencies(
759852 return config.GetManager().Watch()
760853 },
761854 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,
765861 }
766862
767863 return uiDeps, nil
M cmd/dumber/main_test.go +107 -0
@@ -1,9 +1,11 @@
11 package main
22
33 import (
4+ "context"
45 "errors"
56 "testing"
67
8+ "github.com/bnema/dumber/internal/application/port/mocks"
79 "github.com/bnema/dumber/internal/bootstrap"
810 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
911 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -70,6 +72,68 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
7072 }
7173 }
7274
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+
73137 func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
74138 got, err := resolveCurrentExecutable(func() (string, error) {
75139 return "/usr/bin/dumber", nil
@@ -131,3 +195,46 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
131195 t.Fatal("expected adwaita detector to remain unavailable")
132196 }
133197 }
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
104104 | Key | Type | Default | Valid Values | Description |
105105 |-----|------|---------|--------------|-------------|
106106 | `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) |
107108 | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane |
108109
109110 **Example:**
110111 ```toml
111112 [omnibox]
112113 initial_behavior = "recent" # Show recent history when omnibox opens
114+most_visited_days = 30 # Days of history used for most_visited
113115 auto_open_on_new_pane = false
114116
115117 # Alternative options:
M docs/reference/configuration.md +1 -0
@@ -18,6 +18,7 @@
1818 | `dmenu.date_format` | string | `2006-01-02 15:04` | Go time format |
1919 | `dmenu.sort_by_visit_count` | bool | `true` | |
2020 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |
21+| `omnibox.most_visited_days` | int | `30` | `>= 0` |
2122 | `omnibox.auto_open_on_new_pane` | bool | `false` | |
2223 | `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |
2324 | `logging.format` | string | `text` | `text`, `json` |
M go.mod +10 -13
@@ -3,27 +3,26 @@ module github.com/bnema/dumber
33 go 1.26.1
44
55 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
1212 github.com/charmbracelet/bubbles v1.0.0
1313 github.com/charmbracelet/bubbletea v1.3.10
1414 github.com/charmbracelet/lipgloss v1.1.0
15- github.com/ebitengine/purego v0.10.0
1615 github.com/fsnotify/fsnotify v1.9.0
1716 github.com/godbus/dbus/v5 v5.2.2
1817 github.com/google/uuid v1.6.0
1918 github.com/pelletier/go-toml/v2 v2.3.0
2019 github.com/pressly/goose/v3 v3.27.0
21- github.com/rs/zerolog v1.34.0
20+ github.com/rs/zerolog v1.35.1
2221 github.com/spf13/cobra v1.10.2
2322 github.com/spf13/viper v1.21.0
2423 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
2726 gopkg.in/yaml.v3 v3.0.1
2827 )
2928
@@ -66,9 +65,7 @@ require (
6665 go.uber.org/multierr v1.11.0 // indirect
6766 go.yaml.in/yaml/v3 v3.0.4 // indirect
6867 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
7069 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7170 modernc.org/sqlite v1.47.0 // indirect
7271 )
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 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
48
59 // Clipboard defines the port interface for clipboard operations.
610 // This abstracts platform-specific clipboard implementations (GTK, etc.).
@@ -8,6 +12,9 @@ type Clipboard interface {
812 // WriteText copies text to the clipboard.
913 WriteText(ctx context.Context, text string) error
1014
15+ // WriteImage copies encoded image bytes to the clipboard.
16+ WriteImage(ctx context.Context, image entity.ImageData) error
17+
1118 // ReadText reads text from the clipboard.
1219 // Returns empty string if clipboard is empty or contains non-text data.
1320 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 {
66 // to new ActionBinding format (map with keys and desc).
77 // Modifies the rawConfig map in place.
88 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)
913 }
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
88 const (
99 // DownloadEventStarted indicates a download has begun.
1010 DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventProgress indicates download progress has advanced.
12+ DownloadEventProgress
1113 // DownloadEventFinished indicates a download completed successfully.
1214 DownloadEventFinished
1315 // DownloadEventFailed indicates a download failed.
@@ -16,10 +18,13 @@ const (
1618
1719 // DownloadEvent contains information about a download event.
1820 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
2328 }
2429
2530 // DownloadEventHandler receives download event notifications.
M internal/application/port/engine.go +6 -5
@@ -193,11 +193,12 @@ type WebUIMessage struct {
193193
194194 // HandlerDependencies holds use cases needed by WebUI message handlers.
195195 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)
201202 HandlerDeps
202203 }
203204
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 (
88 "context"
99 "database/sql"
1010 "encoding/json"
11+ "io"
1112 "time"
1213
14+ "github.com/bnema/dumber/internal/application/dto"
1315 "github.com/bnema/dumber/internal/application/port"
1416 "github.com/bnema/dumber/internal/domain/entity"
1517 "github.com/rs/zerolog"
@@ -172,6 +174,251 @@ func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, se
172174 return _c
173175 }
174176
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+
175422 // 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.
176423 // The first argument is typically a *testing.T value.
177424 func NewMockClipboard(t interface {
@@ -370,6 +617,63 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
370617 return _c
371618 }
372619
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+
373677 // WriteText provides a mock function for the type MockClipboard
374678 func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error {
375679 ret := _mock.Called(ctx, text)
@@ -427,13 +731,13 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
427731 return _c
428732 }
429733
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.
431735 // The first argument is typically a *testing.T value.
432-func NewMockColorSchemeDetector(t interface {
736+func NewMockClipboardTextOrchestrator(t interface {
433737 mock.TestingT
434738 Cleanup(func())
435-}) *MockColorSchemeDetector {
436- mock := &MockColorSchemeDetector{}
739+}) *MockClipboardTextOrchestrator {
740+ mock := &MockClipboardTextOrchestrator{}
437741 mock.Mock.Test(t)
438742
439743 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -441,59 +745,200 @@ func NewMockColorSchemeDetector(t interface {
441745 return mock
442746 }
443747
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 {
446750 mock.Mock
447751 }
448752
449-type MockColorSchemeDetector_Expecter struct {
753+type MockClipboardTextOrchestrator_Expecter struct {
450754 mock *mock.Mock
451755 }
452756
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}
455759 }
456760
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)
460764
461765 if len(ret) == 0 {
462- panic("no return value specified for Available")
766+ panic("no return value specified for HandleExplicitCopy")
463767 }
464768
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)
468772 } else {
469- r0 = ret.Get(0).(bool)
773+ r0 = ret.Error(0)
470774 }
471775 return r0
472776 }
473777
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 {
476780 *mock.Call
477781 }
478782
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)}
482788 }
483789
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 {
485791 _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+ )
487804 })
488805 return _c
489806 }
490807
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)
493810 return _c
494811 }
495812
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 {
497942 _c.Call.Return(run)
498943 return _c
499944 }
@@ -1387,47 +1832,371 @@ func (_mock *MockConfigTransformer) TransformLegacyActions(rawConfig map[string]
13871832 return
13881833 }
13891834
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 {
13922155 *mock.Call
13932156 }
13942157
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)}
13992163 }
14002164
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 {
14022166 _c.Call.Run(func(args mock.Arguments) {
1403- var arg0 map[string]any
2167+ var arg0 context.Context
14042168 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)
14062174 }
14072175 run(
14082176 arg0,
2177+ arg1,
14092178 )
14102179 })
14112180 return _c
14122181 }
14132182
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)
14162185 return _c
14172186 }
14182187
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)
14212190 return _c
14222191 }
14232192
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.
14252194 // The first argument is typically a *testing.T value.
1426-func NewMockContentInjector(t interface {
2195+func NewMockContextMenuActionExecutor(t interface {
14272196 mock.TestingT
14282197 Cleanup(func())
1429-}) *MockContentInjector {
1430- mock := &MockContentInjector{}
2198+}) *MockContextMenuActionExecutor {
2199+ mock := &MockContextMenuActionExecutor{}
14312200 mock.Mock.Test(t)
14322201
14332202 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -1435,186 +2204,266 @@ func NewMockContentInjector(t interface {
14352204 return mock
14362205 }
14372206
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 {
14402209 mock.Mock
14412210 }
14422211
1443-type MockContentInjector_Expecter struct {
2212+type MockContextMenuActionExecutor_Expecter struct {
14442213 mock *mock.Mock
14452214 }
14462215
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}
14492218 }
14502219
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)
14542223
14552224 if len(ret) == 0 {
1456- panic("no return value specified for InjectFindHighlightCSS")
2225+ panic("no return value specified for ExecuteMenuAction")
14572226 }
14582227
14592228 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)
14622231 } else {
14632232 r0 = ret.Error(0)
14642233 }
14652234 return r0
14662235 }
14672236
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 {
14702239 *mock.Call
14712240 }
14722241
1473-// InjectFindHighlightCSS is a helper method to define mock.On call
2242+// ExecuteMenuAction is a helper method to define mock.On call
14742243 // - 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)}
14782248 }
14792249
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 {
14812251 _c.Call.Run(func(args mock.Arguments) {
14822252 var arg0 context.Context
14832253 if args[0] != nil {
14842254 arg0 = args[0].(context.Context)
14852255 }
1486- var arg1 string
2256+ var arg1 port.MenuAction
14872257 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)
14892263 }
14902264 run(
14912265 arg0,
14922266 arg1,
2267+ arg2,
14932268 )
14942269 })
14952270 return _c
14962271 }
14972272
1498-func (_c *MockContentInjector_InjectFindHighlightCSS_Call) Return(err error) *MockContentInjector_InjectFindHighlightCSS_Call {
2273+func (_c *MockContextMenuActionExecutor_ExecuteMenuAction_Call) Return(err error) *MockContextMenuActionExecutor_ExecuteMenuAction_Call {
14992274 _c.Call.Return(err)
15002275 return _c
15012276 }
15022277
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 {
15042279 _c.Call.Return(run)
15052280 return _c
15062281 }
15072282
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)
15112313
15122314 if len(ret) == 0 {
1513- panic("no return value specified for InjectThemeCSS")
2315+ panic("no return value specified for NewContextMenuActionExecutor")
15142316 }
15152317
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)
15192321 } else {
1520- r0 = ret.Error(0)
2322+ if ret.Get(0) != nil {
2323+ r0 = ret.Get(0).(port.ContextMenuActionExecutor)
2324+ }
15212325 }
15222326 return r0
15232327 }
15242328
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 {
15272331 *mock.Call
15282332 }
15292333
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)}
15352341 }
15362342
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 {
15382344 _c.Call.Run(func(args mock.Arguments) {
1539- var arg0 context.Context
2345+ var arg0 port.Clipboard
15402346 if args[0] != nil {
1541- arg0 = args[0].(context.Context)
2347+ arg0 = args[0].(port.Clipboard)
15422348 }
1543- var arg1 string
2349+ var arg1 port.ImageDataResolver
15442350 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)
15462360 }
15472361 run(
15482362 arg0,
15492363 arg1,
2364+ arg2,
2365+ arg3,
15502366 )
15512367 })
15522368 return _c
15532369 }
15542370
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)
15572373 return _c
15582374 }
15592375
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 {
15612377 _c.Call.Return(run)
15622378 return _c
15632379 }
15642380
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)
15682411
15692412 if len(ret) == 0 {
1570- panic("no return value specified for RefreshScripts")
2413+ panic("no return value specified for DelegateMenuAction")
15712414 }
15722415
15732416 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)
15762419 } else {
15772420 r0 = ret.Error(0)
15782421 }
15792422 return r0
15802423 }
15812424
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 {
15842427 *mock.Call
15852428 }
15862429
1587-// RefreshScripts is a helper method to define mock.On call
2430+// DelegateMenuAction is a helper method to define mock.On call
15882431 // - 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)}
15922436 }
15932437
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 {
15952439 _c.Call.Run(func(args mock.Arguments) {
15962440 var arg0 context.Context
15972441 if args[0] != nil {
15982442 arg0 = args[0].(context.Context)
15992443 }
1600- var arg1 port.WebView
2444+ var arg1 port.MenuAction
16012445 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)
16032451 }
16042452 run(
16052453 arg0,
16062454 arg1,
2455+ arg2,
16072456 )
16082457 })
16092458 return _c
16102459 }
16112460
1612-func (_c *MockContentInjector_RefreshScripts_Call) Return(err error) *MockContentInjector_RefreshScripts_Call {
2461+func (_c *MockMenuActionDelegator_DelegateMenuAction_Call) Return(err error) *MockMenuActionDelegator_DelegateMenuAction_Call {
16132462 _c.Call.Return(err)
16142463 return _c
16152464 }
16162465
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 {
16182467 _c.Call.Return(run)
16192468 return _c
16202469 }
@@ -7236,63 +8085,246 @@ func (_c *MockIdleInhibitor_IsInhibited_Call) Run(run func()) *MockIdleInhibitor
72368085 return _c
72378086 }
72388087
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)
72418234 return _c
72428235 }
72438236
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 {
72458238 _c.Call.Return(run)
72468239 return _c
72478240 }
72488241
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)
72528272
72538273 if len(ret) == 0 {
7254- panic("no return value specified for Uninhibit")
8274+ panic("no return value specified for SaveResolvedImage")
72558275 }
72568276
72578277 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)
72608280 } else {
72618281 r0 = ret.Error(0)
72628282 }
72638283 return r0
72648284 }
72658285
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 {
72688288 *mock.Call
72698289 }
72708290
7271-// Uninhibit is a helper method to define mock.On call
8291+// SaveResolvedImage is a helper method to define mock.On call
72728292 // - 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)}
72758297 }
72768298
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 {
72788300 _c.Call.Run(func(args mock.Arguments) {
72798301 var arg0 context.Context
72808302 if args[0] != nil {
72818303 arg0 = args[0].(context.Context)
72828304 }
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+ }
72838313 run(
72848314 arg0,
8315+ arg1,
8316+ arg2,
72858317 )
72868318 })
72878319 return _c
72888320 }
72898321
7290-func (_c *MockIdleInhibitor_Uninhibit_Call) Return(err error) *MockIdleInhibitor_Uninhibit_Call {
8322+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call {
72918323 _c.Call.Return(err)
72928324 return _c
72938325 }
72948326
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 {
72968328 _c.Call.Return(run)
72978329 return _c
72988330 }
@@ -13298,13 +14330,13 @@ func (_c *MockPrinter_PrintPage_Call) RunAndReturn(run func()) *MockPrinter_Prin
1329814330 return _c
1329914331 }
1330014332
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.
1330214334 // The first argument is typically a *testing.T value.
13303-func NewMockPopupCapable(t interface {
14335+func NewMockPopupLifecycleCapable(t interface {
1330414336 mock.TestingT
1330514337 Cleanup(func())
13306-}) *MockPopupCapable {
13307- mock := &MockPopupCapable{}
14338+}) *MockPopupLifecycleCapable {
14339+ mock := &MockPopupLifecycleCapable{}
1330814340 mock.Mock.Test(t)
1330914341
1331014342 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -13312,37 +14344,77 @@ func NewMockPopupCapable(t interface {
1331214344 return mock
1331314345 }
1331414346
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 {
1331714349 mock.Mock
1331814350 }
1331914351
13320-type MockPopupCapable_Expecter struct {
14352+type MockPopupLifecycleCapable_Expecter struct {
1332114353 mock *mock.Mock
1332214354 }
1332314355
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
1332614398 }
1332714399
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()) {
1333014402 _mock.Called(fn)
1333114403 return
1333214404 }
1333314405
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 {
1333614408 *mock.Call
1333714409 }
1333814410
1333914411 // SetOnClose is a helper method to define mock.On call
1334014412 // - 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)}
1334314415 }
1334414416
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 {
1334614418 _c.Call.Run(func(args mock.Arguments) {
1334714419 var arg0 func()
1334814420 if args[0] != nil {
@@ -13355,34 +14427,34 @@ func (_c *MockPopupCapable_SetOnClose_Call) Run(run func(fn func())) *MockPopupC
1335514427 return _c
1335614428 }
1335714429
13358-func (_c *MockPopupCapable_SetOnClose_Call) Return() *MockPopupCapable_SetOnClose_Call {
14430+func (_c *MockPopupLifecycleCapable_SetOnClose_Call) Return() *MockPopupLifecycleCapable_SetOnClose_Call {
1335914431 _c.Call.Return()
1336014432 return _c
1336114433 }
1336214434
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 {
1336414436 _c.Run(run)
1336514437 return _c
1336614438 }
1336714439
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()) {
1337014442 _mock.Called(fn)
1337114443 return
1337214444 }
1337314445
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 {
1337614448 *mock.Call
1337714449 }
1337814450
1337914451 // SetOnReadyToShow is a helper method to define mock.On call
1338014452 // - 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)}
1338314455 }
1338414456
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 {
1338614458 _c.Call.Run(func(args mock.Arguments) {
1338714459 var arg0 func()
1338814460 if args[0] != nil {
@@ -13395,49 +14467,246 @@ func (_c *MockPopupCapable_SetOnReadyToShow_Call) Run(run func(fn func())) *Mock
1339514467 return _c
1339614468 }
1339714469
13398-func (_c *MockPopupCapable_SetOnReadyToShow_Call) Return() *MockPopupCapable_SetOnReadyToShow_Call {
14470+func (_c *MockPopupLifecycleCapable_SetOnReadyToShow_Call) Return() *MockPopupLifecycleCapable_SetOnReadyToShow_Call {
1339914471 _c.Call.Return()
1340014472 return _c
1340114473 }
1340214474
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 {
1340414476 _c.Run(run)
1340514477 return _c
1340614478 }
1340714479
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() {
1341014482 _mock.Called()
1341114483 return
1341214484 }
1341314485
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 {
1341614488 *mock.Call
1341714489 }
1341814490
1341914491 // 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")}
1342214494 }
1342314495
13424-func (_c *MockPopupCapable_Show_Call) Run(run func()) *MockPopupCapable_Show_Call {
14496+func (_c *MockPopupLifecycleCapable_Show_Call) Run(run func()) *MockPopupLifecycleCapable_Show_Call {
1342514497 _c.Call.Run(func(args mock.Arguments) {
1342614498 run()
1342714499 })
1342814500 return _c
1342914501 }
1343014502
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 {
1343214657 _c.Call.Return()
1343314658 return _c
1343414659 }
1343514660
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 {
1343714662 _c.Run(run)
1343814663 return _c
1343914664 }
1344014665
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+
1344114710 // 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.
1344214711 // The first argument is typically a *testing.T value.
1344314712 func NewMockOAuthCallbackCapable(t interface {
@@ -14102,6 +15371,59 @@ func (_c *MockXDGPaths_ManDir_Call) RunAndReturn(run func() (string, error)) *Mo
1410215371 return _c
1410315372 }
1410415373
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+
1410515427 // StateDir provides a mock function for the type MockXDGPaths
1410615428 func (_mock *MockXDGPaths) StateDir() (string, error) {
1410715429 ret := _mock.Called()
M internal/application/port/session_state.go +12 -0
@@ -21,6 +21,18 @@ type SessionSpawner interface {
2121 SpawnWithSession(sessionID entity.SessionID) error
2222 }
2323
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+
2436 // SnapshotService manages debounced session-state snapshots.
2537 type SnapshotService interface {
2638 // Start begins the snapshot service with the given context.
M internal/application/port/webview.go +24 -12
@@ -3,9 +3,7 @@
33 // remain independent of specific implementations (WebKit, GTK, etc.).
44 package port
55
6-import (
7- "context"
8-)
6+import "context"
97
108 // WebViewID uniquely identifies a WebView instance.
119 type WebViewID uint64
@@ -64,10 +62,11 @@ const (
6462
6563 // PopupRequest contains metadata about a popup window request.
6664 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
7170 }
7271
7372 // Texture represents a graphics texture returned by the engine.
@@ -314,14 +313,27 @@ type Printer interface {
314313 PrintPage()
315314 }
316315
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 {
322322 SetOnReadyToShow(fn func())
323323 SetOnClose(fn func())
324324 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
325337 }
326338
327339 // 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 {
55 ConfigDir() (string, error)
66 DataDir() (string, error)
77 StateDir() (string, error)
8+ RuntimeDir() (string, error)
89 CacheDir() (string, error)
910
1011 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
1515 // Execute transforms legacy config format in place.
1616 func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) {
1717 uc.transformer.TransformLegacyActions(rawConfig)
18+ uc.transformer.TransformLegacyEngineConfig(rawConfig)
1819 }
M internal/application/usecase/transform_legacy_config_test.go +2 -1
@@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) {
1313 rawConfig := map[string]any{"test": "data"}
1414
1515 mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return()
16+ mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return()
1617
1718 uc := NewTransformLegacyConfigUseCase(mockTransformer)
1819 uc.Execute(rawConfig)
@@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) {
3334 },
3435 }
3536
36- // Verify the transformer receives the exact rawConfig
3737 mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return()
38+ mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return()
3839
3940 uc := NewTransformLegacyConfigUseCase(mockTransformer)
4041 uc.Execute(rawConfig)
M internal/bootstrap/engine.go +50 -25
@@ -9,9 +9,11 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
1111 "github.com/bnema/dumber/internal/infrastructure/cef"
12+ clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
1213 "github.com/bnema/dumber/internal/infrastructure/config"
1314 "github.com/bnema/dumber/internal/infrastructure/env"
1415 "github.com/bnema/dumber/internal/infrastructure/handlers"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1517 "github.com/bnema/dumber/internal/infrastructure/transcoder"
1618 "github.com/bnema/dumber/internal/infrastructure/webkit"
1719 "github.com/bnema/dumber/internal/ui/theme"
@@ -20,13 +22,12 @@ import (
2022
2123 // EngineInput holds the input for BuildEngine.
2224 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
3031 }
3132
3233 // 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) {
4546 }
4647 currentConfigPayload := buildConfigPayload(systemviewUC.Current)
4748 defaultConfigPayload := buildConfigPayload(systemviewUC.Default)
49+ contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
50+ contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
4851 engineType := cfg.Engine.ResolveEngineType()
4952 switch engineType {
5053 case config.EngineTypeWebKit:
54+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
55+ if err != nil {
56+ return nil, err
57+ }
5158 opts := port.EngineOptions{
52- DataDir: input.DataDir,
53- CacheDir: input.CacheDir,
5459 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
5560 }
5661 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)
5762
5863 return webkit.NewEngine(
59- input.Ctx, cfg, opts, wkCfg,
64+ input.Ctx, cfg, opts, profile, wkCfg,
6065 currentConfigPayload, defaultConfigPayload,
61- input.ThemeManager, input.ColorResolver, input.Logger,
66+ input.ThemeManager, input.ColorResolver,
67+ contextMenuBuilder, contextMenuExecutorFactory,
68+ input.Logger,
6269 )
6370 case config.EngineTypeCEF:
71+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
72+ if err != nil {
73+ return nil, err
74+ }
6475 opts := port.EngineOptions{
65- DataDir: input.DataDir,
66- CacheDir: input.CacheDir,
6776 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
6877 }
6978 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,
7785 }
7886 transcodingCfg := cef.TranscodingRuntimeConfig{
7987 Enabled: cfg.Transcoding.Enabled,
@@ -82,10 +90,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
8290 Quality: cfg.Transcoding.Quality,
8391 }
8492 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(),
89101 MediaClassifier: cef.MediaClassifier{
90102 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
91103 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
@@ -96,8 +108,21 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
96108 },
97109 }
98110 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)
100115 default:
101116 return nil, fmt.Errorf("unknown engine type: %q", engineType)
102117 }
103118 }
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
22
33 import (
44 "context"
5+ "path/filepath"
56 "testing"
67
78 "github.com/bnema/dumber/internal/infrastructure/cef"
89 "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
911 "github.com/stretchr/testify/require"
1012 )
1113
@@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
2123 cfg := config.DefaultConfig()
2224 cfg.Engine.Type = "cef"
2325 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)})
2527 require.Error(t, err)
2628 require.Contains(t, err.Error(), "cef.InitWithApp")
2729 }
@@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) {
3032 cfg := config.DefaultConfig()
3133 cfg.Engine.Type = "cef"
3234 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)})
3436 require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
3537 }
3638
@@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
3941 cfg.Engine.Type = "cef"
4042 cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty
4143 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)})
4345 require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
4446 }
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 (
66 "database/sql"
77 "fmt"
88 "os"
9- "path/filepath"
109 "sync"
1110 "time"
1211
@@ -18,6 +17,7 @@ import (
1817 "github.com/bnema/dumber/internal/infrastructure/env"
1918 "github.com/bnema/dumber/internal/infrastructure/media"
2019 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2121 "github.com/bnema/dumber/internal/logging"
2222 "github.com/bnema/dumber/internal/ui/theme"
2323 )
@@ -30,8 +30,7 @@ type DatabaseResult struct {
3030
3131 // ParallelInitResult holds the results of parallel initialization phase.
3232 type ParallelInitResult struct {
33- DataDir string
34- CacheDir string
33+ RuntimeProfile runtimeprofile.Profile
3534 ThemeManager *theme.Manager
3635 ColorResolver port.ColorSchemeResolver
3736 AdwaitaDetector *colorscheme.AdwaitaDetector
@@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
9190 // Returns the first fatal error encountered, or nil with the results.
9291 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
9392 var (
94- dataDir, cacheDir string
95- dirsErr error
96- wg sync.WaitGroup
93+ dirsErr error
94+ wg sync.WaitGroup
9795 )
9896
97+ profile, err := ResolveRuntimeProfile(input.Config)
98+ if err != nil {
99+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
100+ }
101+
99102 start := time.Now()
100103
101104 // Create color scheme resolver with initial detectors.
@@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
116119 // Resolve directories
117120 go func() {
118121 defer wg.Done()
119- dataDir, cacheDir, dirsErr = resolveWebKitDirs()
122+ dirsErr = resolveWebKitDirs(profile)
120123 }()
121124
122125 // Theme manager (CPU-bound, no I/O)
@@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
138141 }
139142
140143 return &ParallelInitResult{
141- DataDir: dataDir,
142- CacheDir: cacheDir,
144+ RuntimeProfile: profile,
143145 ThemeManager: themeManager,
144146 ColorResolver: resolver,
145147 AdwaitaDetector: adwaitaDetector,
@@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
218220 }
219221
220222 // 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)
226229 }
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)
234232 }
235- return dataDir, cacheDir, nil
233+ return nil
236234 }
237235
238236 // OpenDatabase opens and initializes the SQLite database using XDG paths.
@@ -277,12 +275,11 @@ type ParallelDBEngineResult struct {
277275
278276 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
279277 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
286283 }
287284
288285 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult,
317314
318315 // Engine on main thread (GTK requirement)
319316 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,
327323 })
328324 if err != nil {
329325 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 (
1010
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/bootstrap"
1314 "github.com/bnema/dumber/internal/cli/styles"
1415 "github.com/bnema/dumber/internal/domain/build"
1516 "github.com/bnema/dumber/internal/domain/repository"
@@ -37,6 +38,7 @@ type App struct {
3738
3839 // Services
3940 FaviconService *favicon.Service
41+ SessionSpawner port.SessionSpawner
4042
4143 // Context with logger
4244 ctx context.Context
@@ -49,6 +51,10 @@ func NewApp() (*App, error) {
4951 var err error
5052 // Load config
5153 cfg := loadConfig()
54+ profile, err := bootstrap.ResolveRuntimeProfile(cfg)
55+ if err != nil {
56+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
57+ }
5258
5359 // Create theme from config
5460 theme := styles.NewTheme(cfg)
@@ -131,6 +137,7 @@ func NewApp() (*App, error) {
131137 RestoreUC: restoreUC,
132138 DeleteSessionUC: deleteSessionUC,
133139 FaviconService: faviconService,
140+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
134141 ctx: ctx,
135142 logCleanup: logCleanup,
136143 }, nil
M internal/cli/cmd/gen_docs.go +1 -1
@@ -57,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error {
5757 if outputDir == "" {
5858 switch genDocsFormat {
5959 case "man":
60- xdg := xdgadapter.New()
60+ xdg := xdgadapter.New(false, "")
6161 manDir, err := xdg.ManDir()
6262 if err != nil {
6363 return fmt.Errorf("resolve man directory: %w", err)
M internal/cli/cmd/purge.go +11 -2
@@ -7,11 +7,13 @@ import (
77 "github.com/spf13/cobra"
88
99 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/bootstrap"
1011 "github.com/bnema/dumber/internal/cli"
1112 "github.com/bnema/dumber/internal/cli/model"
1213 "github.com/bnema/dumber/internal/cli/styles"
1314 "github.com/bnema/dumber/internal/infrastructure/desktop"
1415 "github.com/bnema/dumber/internal/infrastructure/filesystem"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1517 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
1618 )
1719
@@ -47,7 +49,14 @@ func runPurge(_ *cobra.Command, _ []string) error {
4749 }
4850
4951 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+ )
5160 desktopAdapter := desktop.New()
5261 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)
5362
@@ -63,7 +72,7 @@ func runPurge(_ *cobra.Command, _ []string) error {
6372
6473 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
6574 p := tea.NewProgram(m, tea.WithAltScreen())
66- _, err := p.Run()
75+ _, err = p.Run()
6776 return err
6877 }
6978
M internal/cli/cmd/sessions.go +7 -3
@@ -13,7 +13,6 @@ import (
1313 "github.com/bnema/dumber/internal/cli/model"
1414 "github.com/bnema/dumber/internal/cli/styles"
1515 "github.com/bnema/dumber/internal/domain/entity"
16- "github.com/bnema/dumber/internal/infrastructure/desktop"
1716 )
1817
1918 const defaultSessionsLimit = 20
@@ -60,6 +59,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
6059 ListSessionsUC: app.ListSessionsUC,
6160 RestoreUC: app.RestoreUC,
6261 DeleteSessionUC: app.DeleteSessionUC,
62+ SessionSpawner: app.SessionSpawner,
6363 CurrentSession: currentSessionID,
6464 MaxExitedSessions: app.Config.Session.MaxExitedSessions,
6565 })
@@ -181,8 +181,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
181181 }
182182
183183 // 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 {
186190 wrappedErr := fmt.Errorf("spawn browser: %w", err)
187191 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
188192 return wrapPrintedError(wrappedErr)
M internal/cli/model/sessions.go +8 -3
@@ -11,10 +11,10 @@ import (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/charmbracelet/lipgloss"
1313
14+ "github.com/bnema/dumber/internal/application/port"
1415 "github.com/bnema/dumber/internal/application/usecase"
1516 "github.com/bnema/dumber/internal/cli/styles"
1617 "github.com/bnema/dumber/internal/domain/entity"
17- "github.com/bnema/dumber/internal/infrastructure/desktop"
1818 "github.com/bnema/dumber/internal/logging"
1919 )
2020
@@ -43,6 +43,7 @@ type SessionsModel struct {
4343 listSessionsUC *usecase.ListSessionsUseCase
4444 restoreUC *usecase.RestoreSessionUseCase
4545 deleteSessionUC *usecase.DeleteSessionUseCase
46+ sessionSpawner port.SessionSpawner
4647 theme *styles.Theme
4748 }
4849
@@ -114,6 +115,7 @@ type SessionsModelConfig struct {
114115 ListSessionsUC *usecase.ListSessionsUseCase
115116 RestoreUC *usecase.RestoreSessionUseCase
116117 DeleteSessionUC *usecase.DeleteSessionUseCase
118+ SessionSpawner port.SessionSpawner
117119 CurrentSession entity.SessionID
118120 MaxExitedSessions int
119121 }
@@ -137,6 +139,7 @@ func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsMode
137139 listSessionsUC: cfg.ListSessionsUC,
138140 restoreUC: cfg.RestoreUC,
139141 deleteSessionUC: cfg.DeleteSessionUC,
142+ sessionSpawner: cfg.SessionSpawner,
140143 currentSession: cfg.CurrentSession,
141144 theme: theme,
142145 }
@@ -336,8 +339,10 @@ func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
336339 }
337340
338341 // 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 {
341346 return sessionRestoredMsg{sessionID: sessionID, err: err}
342347 }
343348
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 @@
11 package cef
22
33 import (
4+ "strings"
5+
46 purecef "github.com/bnema/purego-cef/cef"
57
68 "github.com/bnema/dumber/internal/logging"
@@ -43,6 +45,7 @@ func configureCommandLine(commandLine purecef.CommandLine) {
4345 type dumberApp struct {
4446 engine *Engine
4547 bph purecef.BrowserProcessHandler
48+ rph purecef.RenderProcessHandler
4649 }
4750
4851 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -51,13 +54,16 @@ type dumberApp struct {
5154 func newDumberApp(engine *Engine) purecef.App {
5255 app := &dumberApp{engine: engine}
5356 app.bph = &dumberBPH{engine: engine}
57+ app.rph = newPopupOpenerRenderProcessHandler()
5458 return app
5559 }
5660
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.
5965 func NewSubprocessApp() purecef.App {
60- return purecef.NewApp(&subprocessApp{})
66+ return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}
6167 }
6268
6369 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -87,9 +93,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
8793 }
8894 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
8995 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 }
9197
92-type subprocessApp struct{}
98+type subprocessApp struct{ rph purecef.RenderProcessHandler }
9399
94100 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
95101 configureCommandLine(commandLine)
@@ -99,7 +105,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
99105 }
100106 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
101107 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 }
103109
104110 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
105111 // child process launch tracking, and diagnostic logging.
@@ -118,6 +124,7 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
118124 useAngle := ""
119125 ozonePlatform := ""
120126 if commandLine != nil {
127+ appendSwitchIfMissing(commandLine, "no-zygote")
121128 processType = commandLine.GetSwitchValue("type")
122129 commandLineString = commandLine.GetCommandLineString()
123130 useAngle = commandLine.GetSwitchValue("use-angle")
@@ -125,9 +132,100 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
125132 }
126133 h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
127134 }
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 }
131229
132230 // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives
133231 // its own pump. Required by the BrowserProcessHandler interface.
M internal/infrastructure/cef/app_test.go +159 -0
@@ -1,7 +1,9 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
6+ "unsafe"
57
68 purecef "github.com/bnema/purego-cef/cef"
79 )
@@ -19,6 +21,61 @@ func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32)
1921 return 1
2022 }
2123
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+
2279 func TestRegisterDumbScheme(t *testing.T) {
2380 t.Parallel()
2481
@@ -41,3 +98,105 @@ func TestRegisterDumbScheme(t *testing.T) {
4198 t.Fatalf("options = %d, want %d", stub.options, wantOptions)
4299 }
43100 }
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(){
385385 }, 1000);
386386 })();`
387387
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-
415388 // contentInjector implements port.ContentInjector for the CEF engine.
416389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
417390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -532,8 +505,18 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
532505 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
533506 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
534507
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+ }
537520
538521 // Video playback diagnostic — logs video element state changes.
539522 // 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 @@
11 package cef
22
33 import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
48 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"
612 "github.com/bnema/puregotk/v4/gtk"
713 )
814
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-
1615 // ===========================================================================
1716 // ContextMenuHandler (implemented on handlerSet)
1817 // ===========================================================================
@@ -25,98 +24,315 @@ func (h *handlerSet) RunContextMenu(
2524 params purecef.ContextMenuParams, model purecef.MenuModel,
2625 callback purecef.RunContextMenuCallback,
2726 ) 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.
2846 count := model.GetCount()
2947 if count == 0 {
3048 callback.Cancel()
3149 return 1
3250 }
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)
3652 for i := range count {
3753 t := model.GetTypeAt(i)
3854 if t == purecef.MenuItemTypeMenuitemtypeSeparator {
39- items = append(items, menuEntry{isSep: true})
4055 continue
4156 }
4257 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+ }
4762 }
4863 }
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
5272 }
5373
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()
5678
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+ )
6095 return 1
6196 }
6297
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
68101 }
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+}
69109
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+}
72119
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+}
75143
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+}
82157
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+}
86164
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")
93182 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)
96198 }
97199 }
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
100208 }
209+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
210+ callback.Cancel()
211+}
101212
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+}
107223
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+}
110239
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")
114258 }
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)
116275 }
117- popover.ConnectClosed(&closedCb)
276+}
118277
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
120336 }
121337
122338 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
22
33 import (
44 "context"
5+ "crypto/subtle"
6+ "fmt"
57 "sync"
68 "sync/atomic"
79 "time"
10+ "unicode/utf8"
811
912 purecef "github.com/bnema/purego-cef/cef"
1013
14+ "github.com/bnema/dumber/internal/application/dto"
1115 "github.com/bnema/dumber/internal/application/port"
1216 "github.com/bnema/dumber/internal/logging"
1317 )
1418
1519 // Compile-time interface check.
1620 var _ port.Engine = (*Engine)(nil)
21+var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
1722
1823 // Engine implements port.Engine for the CEF browser backend.
1924 // It manages the CEF lifecycle and provides access to all engine subsystems.
@@ -27,15 +32,26 @@ type Engine struct {
2732 schemeHandler *dumbSchemeHandler
2833 contentInj *contentInjector
2934
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)
3550
3651 // 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
3955
4056 // shutdownNotify is signaled when the active webview count reaches 0
4157 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
@@ -110,16 +126,66 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
110126 }
111127 }
112128
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+
113143 // registerWebView adds a webview to the active tracking map.
114144 func (e *Engine) registerWebView(wv *WebView) {
115145 e.activeWebViews.Store(wv.id, wv)
116146 e.activeCount.Add(1)
117147 }
118148
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+
119184 // unregisterWebView removes a webview from the active tracking map.
120185 // 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) {
122187 e.activeWebViews.Delete(wv.id)
188+ e.unbindBrowserWebView(browserID, wv)
123189 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
124190 select {
125191 case e.shutdownNotify <- struct{}{}:
@@ -215,6 +281,8 @@ func (e *Engine) closeActiveWebViews() {
215281 // each handler is registered by message type with the router, which dispatches
216282 // incoming /api/message POSTs to the correct handler based on Message.Type.
217283 func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
284+ e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator
285+ e.onClipboardCopied = deps.OnClipboardCopied
218286 if e.messageRouter == nil || e.registerHandlers == nil {
219287 return nil
220288 }
@@ -228,9 +296,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
228296 return e.registerAccentHandlers(ctx, e.messageRouter, handler)
229297 }
230298
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
234313 }
235314
236315 // OnToolkitReady is called after the UI toolkit has initialized.
@@ -255,6 +334,53 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
255334 return nil
256335 }
257336
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+
258384 // SetHandlerContext sets the base context for message handler dispatch.
259385 func (e *Engine) SetHandlerContext(ctx context.Context) {
260386 e.ctx = ctx
@@ -338,6 +464,34 @@ func (e *Engine) currentContext() context.Context {
338464 return e.ctx
339465 }
340466
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+
341495 func (e *Engine) logTranscoderStartupState() {
342496 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
343497 return
M internal/infrastructure/cef/engine_contract_test.go +17 -2
@@ -5,13 +5,28 @@ import (
55 "testing"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/stretchr/testify/require"
910 )
1011
11-func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) {
12+func TestEngineConfigureDownloads_StoresHandler(t *testing.T) {
1213 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+
1325 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())
1530 }
1631
1732 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
M internal/infrastructure/cef/engine_init.go +211 -153
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -22,11 +23,19 @@ import (
2223 )
2324
2425 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+}
2533
2634 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2735 func NewEngine(
2836 ctx context.Context,
2937 opts port.EngineOptions,
38+ paths RuntimePaths,
3039 cfg RuntimeConfig,
3140 transcodingCfg TranscodingRuntimeConfig,
3241 audioFactory port.AudioOutputFactory,
@@ -34,11 +43,11 @@ func NewEngine(
3443 ) (*Engine, error) {
3544 logger := logging.FromContext(ctx)
3645 deps.MediaClassifier = deps.MediaClassifier.normalize()
37- stateRoot := resolvedStateRoot(opts)
46+ stateRoot := resolvedStateRoot(paths.StateRoot, opts)
3847 cleanStaleSingletonLocks(logger, stateRoot)
3948 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
4049
41- settings, err := prepareCEFSettings(opts, cfg, logger)
50+ settings, err := prepareCEFSettings(opts, paths, cfg, logger)
4251 if err != nil {
4352 return nil, err
4453 }
@@ -54,6 +63,10 @@ func NewEngine(
5463 registerAccentHandlers: deps.RegisterAccentHandlers,
5564 currentConfigPayload: deps.CurrentConfigPayload,
5665 defaultConfigPayload: deps.DefaultConfigPayload,
66+ ctxMenuBuilder: deps.ContextMenuBuilder,
67+ ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
68+ clipboard: deps.Clipboard,
69+ resolver: deps.ImageDataResolver,
5770 mediaClassifier: deps.MediaClassifier,
5871 }
5972
@@ -62,7 +75,6 @@ func NewEngine(
6275 Bool("external_begin_frame", externalBeginFrameEnabled()).
6376 Bool("trace_handlers", cfg.TraceHandlers).
6477 Bool("enable_audio_handler", cfg.EnableAudioHandler).
65- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
6678 Msg("cef: configured engine")
6779
6880 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -85,19 +97,26 @@ func NewEngine(
8597 )
8698 }
8799
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 != "" {
93105 return opts.CacheDir
94- default:
95- return defaultCEFUserDataDir()
96106 }
107+ if opts.DataDir != "" {
108+ return opts.DataDir
109+ }
110+ return defaultStateRoot
97111 }
98112
99113 // 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) {
101120 switch opts.CookiePolicy {
102121 case "", port.CookiePolicyAlways:
103122 case port.CookiePolicyNoThirdParty:
@@ -113,49 +132,28 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
113132 settings := purecef.DefaultSettings()
114133 settings.MultiThreadedMessageLoop = true
115134 settings.ExternalMessagePump = false
116- settings.RootCachePath = resolvedStateRoot(opts)
135+ settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
117136 if cfg.CEFDir != "" {
118137 settings.CEFDir = cfg.CEFDir
119138 }
120- if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil {
139+ if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
121140 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
122141 } else {
123142 settings.LogFile = runtimeLogFile
124143 }
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")
133150 }
134151 if cfg.LogSeverity != 0 {
135152 settings.LogSeverity = cfg.LogSeverity
136153 }
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- }
143154 return settings, nil
144155 }
145156
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-
159157 // initializeCEF calls cef_initialize with the App to register custom schemes
160158 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
161159 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
170168
171169 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
172170 func wireEngine(
173- ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
171+ ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
174172 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
175173 mediaClassifier MediaClassifier,
176174 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
@@ -181,52 +179,80 @@ func wireEngine(
181179 return nil, fmt.Errorf("GL loader: %w", err)
182180 }
183181
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
185194
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
212206 }
207+ eng.messageRouter = messageRouter
208+ eng.schemeHandler = schemeHandler
213209
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+}
223213
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+}
229247
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) {
230256 messageRouter := NewMessageRouter(ctx)
231257 schemeHandler, err := newDumbSchemeHandler(
232258 ctx,
@@ -236,15 +262,25 @@ func wireEngine(
236262 defaultConfigPayload,
237263 )
238264 if err != nil {
239- purecef.Shutdown()
240- return nil, err
265+ return nil, nil, err
241266 }
242267 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+}
243278
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) {
248284 fn := glib.SourceOnceFunc(func(_ uintptr) {
249285 if display := gdk.DisplayGetDefault(); display != nil {
250286 if cb := display.GetClipboard(); cb != nil {
@@ -259,32 +295,37 @@ func wireEngine(
259295 })
260296 glib.IdleAddOnce(&fn, 0)
261297 }
298+}
262299
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+}
264304
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+ }
269316 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
317+ return
270318 }
271-
272- result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
273319 if result != 1 {
274320 logger.Error().
275321 Int32("result", result).
276322 Str("origin", actualInternalOrigin).
277323 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
282325 }
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")
288329 }
289330
290331 func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState {
@@ -405,71 +446,88 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
405446 }
406447 }
407448
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)
412464 if err != nil {
413- return ""
465+ return "", err
414466 }
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)
419472 }
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
421480 }
422481
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)
433491 }
434- return runtimeLogFile, nil
492+ return file, nil
435493 }
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)
442496 }
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)
446499 }
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
448505 }
449506
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
453511 }
512+ return os.NewFile(uintptr(fd), path), nil
513+}
454514
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 ""
464519 }
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)
468526 }
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)
471529 }
472- return bootstrapLogFile, nil
530+ return ""
473531 }
474532
475533 func puregoCEFInitTraceEnabled() bool {
M internal/infrastructure/cef/engine_init_test.go +144 -48
@@ -1,99 +1,160 @@
11 package cef
22
33 import (
4+ "bytes"
45 "os"
56 "path/filepath"
67 "testing"
78
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10- "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1112 "github.com/rs/zerolog"
1213 "github.com/stretchr/testify/require"
1314 )
1415
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)
1820 }
1921
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)
2337 }
2438
25-func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
39+func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
2640 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)
2843 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)
3047 }
3148
3249 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
3350 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)
3553 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
3654 }
3755
3856 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
3957 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)
4160 require.NoError(t, err)
4261 }
4362
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")
4669
47- path, err := prepareCEFInitTraceFile("")
70+ path, err := prepareCEFLogFile("", logFile)
4871 require.NoError(t, err)
49- require.Empty(t, path)
50-}
72+ require.Equal(t, logFile, path)
5173
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())
5777
58- path, err := prepareCEFInitTraceFile(cfg.LogFile)
78+ fileInfo, err := os.Stat(logFile)
5979 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())
6281 }
6382
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))
6887
69- logger := zerolog.Nop()
70- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
88+ path, err := prepareCEFLogFile("", logFile)
7189 require.NoError(t, err)
90+ require.Equal(t, logFile, path)
7291
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())
7495 }
7596
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))
84100
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))
87104
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)
90108
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+ )
93151 require.NoError(t, err)
152+ require.Equal(t, profile.CEFLogFile(), settings.LogFile)
94153
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)
97158 }
98159
99160 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
@@ -104,3 +165,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
104165
105166 require.NotNil(t, audioFactory, "Audio factory should not be nil")
106167 }
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 (
55 "errors"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/stretchr/testify/require"
1011 )
@@ -51,3 +52,54 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) {
5152 err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{})
5253 require.ErrorIs(t, err, want)
5354 }
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
22
33 import (
44 "context"
5+ "fmt"
56 "sync/atomic"
7+ "time"
68
79 purecef "github.com/bnema/purego-cef/cef"
810
@@ -17,27 +19,37 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
1719 // gets a unique ID, its own renderPipeline and inputBridge, and an
1820 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
1921 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
3031 }
3132
3233 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
3939 }
4040
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+
4153 // newWebViewFactory returns a factory that will create WebViews using the
4254 // given GL loader and HiDPI scale factor.
4355 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -48,14 +60,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
4860 opts.windowlessFrameRate = 60
4961 }
5062 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,
5970 }
6071 }
6172
@@ -70,16 +81,44 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7081 // asynchronously; the returned WebView is usable immediately but navigation
7182 // will fail with errNoBrowser until OnAfterCreated fires.
7283 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+}
74108
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)
76112
77113 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(),
83122 }
84123
85124 var transcodingHandler purecef.ResourceRequestHandler
@@ -93,14 +132,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
93132 }
94133
95134 handlers := &handlerSet{
96- wv: wv,
97- enableContextMenuHandler: f.enableContextMenuHandler,
98- transcodingHandler: transcodingHandler,
135+ wv: wv,
136+ transcodingHandler: transcodingHandler,
99137 }
100138 wv.handlers = handlers
101139 wv.findCtrl = newFindController()
102140
103141 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+ }
104148 input.attachTo(pipeline.glArea)
105149 wv.input = input
106150
@@ -121,34 +165,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
121165
122166 // Build a CEF client backed by our handlerSet.
123167 // 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
148170 }
149171
150172 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,
152174 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
153175 ) {
154176 wv.pendingCreate = &pendingBrowserCreate{
@@ -158,66 +180,200 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
158180 }
159181
160182 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) {
161189 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")
165201 return
166202 }
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() },
180228 )
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")
204229 }
205230 }
231+}
206232
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)
214265 }
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
215298 }
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+ })
216313 }
217314
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
223379 }
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 (
44 "fmt"
55 "unsafe"
66
7- "github.com/ebitengine/purego"
7+ "github.com/bnema/purego"
88 )
99
1010 // OpenGL constants for PBO-based texture rendering.
M internal/infrastructure/cef/input_bridge.go +86 -4
@@ -3,6 +3,8 @@ package cef
33 import (
44 "context"
55 "sync"
6+ "sync/atomic"
7+ "unicode/utf8"
68
79 purecef "github.com/bnema/purego-cef/cef"
810 "github.com/bnema/puregotk/v4/gdk"
@@ -56,6 +58,14 @@ type inputBridge struct {
5658 // ImeCommitText. Set once during attachTo, read-only afterwards.
5759 clipboard *gdk.Clipboard
5860
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+
5969 // IMContext for dead key / compose sequence support. Held as a field
6070 // to prevent garbage collection while the controller is alive.
6171 imContext *gtk.IMContextSimple
@@ -67,12 +77,32 @@ type inputBridge struct {
6777 // show the text caret.
6878 glArea *gtk.GLArea
6979
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+
7085 // onMiddleClick is called when button 2 (middle) is pressed on a link.
7186 // The callback receives the hovered URI. Set by the factory.
7287 onMiddleClick func(uri string)
7388 }
7489
7590 // 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+
76106 func newInputBridge(ctx context.Context, scale int32) *inputBridge {
77107 return &inputBridge{ctx: ctx, scale: scale}
78108 }
@@ -163,6 +193,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
163193 // Focus — forward to CEF and IMContext.
164194 focus := gtk.NewEventControllerFocus()
165195 focusEnterCb := func(_ gtk.EventControllerFocus) {
196+ ib.gtkFocused.Store(true)
166197 if ib.imContext != nil {
167198 ib.imContext.FocusIn()
168199 }
@@ -170,6 +201,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
170201 }
171202 focus.ConnectEnter(&focusEnterCb)
172203 focusLeaveCb := func(_ gtk.EventControllerFocus) {
204+ ib.gtkFocused.Store(false)
173205 if ib.imContext != nil {
174206 ib.imContext.Reset()
175207 ib.imContext.FocusOut()
@@ -200,6 +232,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
200232 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
201233 mods := uint(state)
202234
235+ ib.maybeMirrorClipboardShortcut(keyval, mods)
236+
203237 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
204238 // Wayland/X11 clipboard, so we read from GDK and inject via
205239 // ImeCommitText instead of letting CEF handle the paste.
@@ -285,6 +319,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
285319 return
286320 }
287321
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+
288329 evt := buildMouseEvent(x, y, mods, ib.scale)
289330 btn := translateMouseButton(button)
290331 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
@@ -321,10 +362,7 @@ func (ib *inputBridge) onFocusIn() {
321362 ib.mu.Lock()
322363 host := ib.host
323364 ib.mu.Unlock()
324- if host == nil {
325- return
326- }
327- host.SetFocus(1)
365+ syncWindowlessBrowserFocus(host)
328366 }
329367
330368 func (ib *inputBridge) onFocusOut() {
@@ -337,6 +375,46 @@ func (ib *inputBridge) onFocusOut() {
337375 host.SetFocus(0)
338376 }
339377
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+
340418 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
341419 ib.mu.Lock()
342420 host := ib.host
@@ -558,8 +636,12 @@ const (
558636 gdkKeyPageDown = 0xff56
559637
560638 // Clipboard shortcut keyvals.
639+ gdkKeyLowercaseC = 0x063
640+ gdkKeyUppercaseC = 0x043
561641 gdkKeyLowercaseV = 0x076
562642 gdkKeyUppercaseV = 0x056
643+ gdkKeyLowercaseX = 0x078
644+ gdkKeyUppercaseX = 0x058
563645 )
564646
565647 // 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 (
1111 actualInternalScheme = "https"
1212 actualInternalHost = "dumber.invalid"
1313 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+ internalAPIPathPrefix = "/api/"
15+ internalAPIPathPrefixTrimmed = "api/"
1416 )
1517
1618 func isConceptualInternalURL(raw string) bool {
@@ -49,7 +51,7 @@ func toActualInternalURL(raw string) string {
4951 case trimmedPath == "":
5052 // Keep page roots at /history instead of /history/ so relative assets
5153 // resolve from the origin root.
52- case strings.HasPrefix(trimmedPath, "api/"):
54+ case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
5355 actualPath = "/" + trimmedPath
5456 case path.Ext(trimmedPath) != "":
5557 actualPath = "/" + trimmedPath
@@ -67,6 +69,36 @@ func toActualInternalURL(raw string) string {
6769 return actual.String()
6870 }
6971
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+
70102 func toConceptualInternalURL(raw string) string {
71103 if raw == "" || !isActualInternalURL(raw) {
72104 return raw
M internal/infrastructure/cef/internal_urls_test.go +64 -1
@@ -1,6 +1,9 @@
11 package cef
22
3-import "testing"
3+import (
4+ "net/url"
5+ "testing"
6+)
47
58 func TestToActualInternalURL(t *testing.T) {
69 t.Parallel()
@@ -67,6 +70,66 @@ func TestToActualInternalURL(t *testing.T) {
6770 }
6871 }
6972
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+
70133 func TestToConceptualInternalURL(t *testing.T) {
71134 t.Parallel()
72135
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 (
77 "time"
88 "unsafe"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
1112 "github.com/bnema/puregotk/v4/gdk"
1213 "github.com/bnema/puregotk/v4/gtk"
@@ -25,9 +26,10 @@ type rect struct {
2526 // and GTK later uploads from staging → PBO → texture before drawing a
2627 // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
2728 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
3133
3234 // GL resources (created on first render).
3335 texture uint32
@@ -71,7 +73,11 @@ type renderPipeline struct {
7173 viewRectSeq atomic.Uint64
7274 screenInfoSeq atomic.Uint64
7375 paintSeq atomic.Uint64
76+ resizeSeq atomic.Uint64
7477 lastQueuedPaintSeq atomic.Uint64
78+ lastResizeUnixNano atomic.Int64
79+ lastResizePaintLogSeq atomic.Uint64
80+ lastResizeStaleLogSeq atomic.Uint64
7581 glRenderSeq atomic.Uint64
7682 paintCount uint64
7783 acceleratedPaintCount uint64
@@ -108,6 +114,27 @@ type renderPipeline struct {
108114 onResizeCB func(width, height int32)
109115 }
110116
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+
111138 // Shader sources for the fullscreen textured quad.
112139 const vertexShaderSource = "" +
113140 "#version 330 core\n" +
@@ -139,15 +166,16 @@ var quadVertices = [16]float32{
139166
140167 // newRenderPipeline creates a GtkGLArea and wires up the render and resize
141168 // 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 {
143170 if scale < 1 {
144171 scale = 1
145172 }
146173
147174 rp := &renderPipeline{
148- ctx: ctx,
149- gl: gl,
150- scale: scale,
175+ webviewID: webviewID,
176+ ctx: ctx,
177+ gl: gl,
178+ scale: scale,
151179 }
152180
153181 rp.glArea = gtk.NewGLArea()
@@ -156,8 +184,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi
156184 rp.glArea.SetHasDepthBuffer(false)
157185 rp.glArea.SetHasStencilBuffer(false)
158186
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)
161190 return rp.onGLRender()
162191 }
163192 rp.glArea.ConnectRender(&renderCb)
@@ -292,6 +321,21 @@ func (rp *renderPipeline) onGLRender() bool {
292321
293322 rp.mu.Unlock()
294323
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+
295339 if !rp.glReady || sizeChanged {
296340 rp.initGL(uploadWidth, uploadHeight)
297341 }
@@ -783,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() {
783827 }
784828
785829 // 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+
786864 func (rp *renderPipeline) buildShaderProgram() uint32 {
787865 gl := rp.gl
788866
@@ -819,13 +897,27 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
819897 // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels;
820898 // we multiply by scale to get device pixels.
821899 func (rp *renderPipeline) onResize(width, height int32) {
900+ if width <= 0 || height <= 0 {
901+ return
902+ }
903+
822904 rp.mu.Lock()
823905
906+ prevWidth := rp.widthAtomic.Load()
907+ prevHeight := rp.heightAtomic.Load()
908+ scale := rp.scale
909+ if scale <= 0 {
910+ scale = 1
911+ }
912+
824913 scaled := func(v int32) int32 {
825- return v * rp.scale
914+ return v * scale
826915 }
827916
828917 w, h := scaled(width), scaled(height)
918+ resizeSeq := rp.resizeSeq.Add(1)
919+ now := time.Now()
920+ rp.lastResizeUnixNano.Store(now.UnixNano())
829921 rp.widthAtomic.Store(w)
830922 rp.heightAtomic.Store(h)
831923 rp.sizeChanged = true
@@ -837,6 +929,22 @@ func (rp *renderPipeline) onResize(width, height int32) {
837929 resizeCB := rp.onResizeCB
838930 rp.mu.Unlock()
839931
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+
840948 if firstCB != nil {
841949 firstCB(w, h)
842950 } else if resizeCB != nil {
@@ -918,6 +1026,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
9181026 return width, height, scale
9191027 }
9201028
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+
9211073 func (rp *renderPipeline) nextViewRectSeq() uint64 {
9221074 return rp.viewRectSeq.Add(1)
9231075 }
M internal/infrastructure/cef/render_pipeline_test.go +28 -0
@@ -80,6 +80,34 @@ func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) {
8080 require.False(t, rp.forceFullUpload)
8181 }
8282
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+
83111 // sortRects sorts rects by (X, Y) so tests can compare without caring about order.
84112 func sortRects(rects []rect) {
85113 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 (
55 "errors"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
89 )
910
1011 var (
1112 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
1314 ErrCookiePolicyUnsupported = errors.New("cef: non-default cookie policy is not supported yet")
1415 )
1516
1617 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
2424 }
2525
2626 type TranscodingRuntimeConfig struct {
@@ -66,9 +66,13 @@ func (m MediaClassifier) normalize() MediaClassifier {
6666 }
6767
6868 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
7478 }
M internal/infrastructure/cef/scheme_handler.go +297 -66
@@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{
4242 errorPath: indexHTML,
4343 }
4444
45+var cefNewResourceHandler = purecef.NewResourceHandler
46+
4547 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
4648 // internal https://dumber.invalid origin used by CEF.
4749 type dumbSchemeHandler struct {
@@ -59,6 +61,23 @@ type dumbSchemeHandler struct {
5961 // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
6062 // Set by the engine to write to the GDK system clipboard.
6163 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
6281 }
6382
6483 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -99,7 +118,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
99118
100119 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
101120 // 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 {
103122 reqURL := request.GetURL()
104123 method := request.GetMethod()
105124
@@ -119,8 +138,18 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
119138 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
120139 }
121140
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.
124153 if isConceptualInternalURL(reqURL) {
125154 targetURL := toActualInternalURL(reqURL)
126155 if targetURL != "" && targetURL != reqURL {
@@ -128,67 +157,103 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
128157 }
129158 }
130159
131- // Route API requests.
132- path := u.Path
133-
134- if strings.HasPrefix(path, "/api/") {
135- return h.handleAPI(method, path, request)
136- }
137-
138160 // Serve static assets.
139161 return h.handleAsset(u)
140162 }
141163
142164 // 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
149172 }
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
153177 }
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+}
161181
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+}
164185
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+}
167189
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
168198 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
170223 }
171224 }
172225
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+
173238 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
174239 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"})
176241 }
177242
178243 reqURL := request.GetURL()
179244 parsed, err := url.Parse(reqURL)
180245 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"})
182247 }
183248
184249 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
185250 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"})
187252 }
188253
189254 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"})
192257 }
193258
194259 headers := make(map[string]string)
@@ -210,7 +275,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
210275 Int("forwarded_header_count", len(headers)).
211276 Msg("scheme handler returning transcoding stream")
212277
213- return purecef.NewResourceHandler(&transcodingResourceHandler{
278+ return cefNewResourceHandler(&transcodingResourceHandler{
214279 transcoder: h.transcoder,
215280 sourceURL: sourceURL,
216281 headers: headers,
@@ -233,26 +298,37 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
233298 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
234299 data, err := resolveConfigPayload(build)
235300 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()})
237302 }
238- return h.newRawResourceHandler(http.StatusOK, "application/json", data)
303+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
239304 }
240305
241306 // handleClipboardSet receives copied text from JS copy/cut events and writes
242307 // 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+)
244315
245-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
316+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
246317 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
247318
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+
248324 body := readBodyFromHeader(request)
249325 if body == nil {
250326 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"})
252328 }
253329 if len(body) > maxClipboardBytes {
254330 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"})
256332 }
257333
258334 var payload struct {
@@ -260,7 +336,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
260336 }
261337 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
262338 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"})
264340 }
265341
266342 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.
271347 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
272348 }
273349
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)
275475 }
276476
277477 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",
285481 })
286482 }
287483
@@ -454,32 +650,63 @@ type staticResourceHandler struct {
454650 offset int
455651 }
456652
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{
459674 data: data,
460675 contentType: contentType,
461676 statusCode: status,
677+ headers: headers,
462678 })
463679 }
464680
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+
465689 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
466690 escaped := html.EscapeString(msg)
467691 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
468692 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
469693 }
470694
471-func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {
695+func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
472696 data, err := json.Marshal(v)
473697 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)
475700 }
476- return h.newRawResourceHandler(status, "application/json", data)
701+ return h.newAPIRawResourceHandler(status, "application/json", data)
477702 }
478703
479704 // 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 {
481706 // Set handleRequest = true (1) to indicate we handle it immediately.
482- *(*int32)(handleRequest) = 1
707+ if handleRequest != nil {
708+ *handleRequest = 1
709+ }
483710 return 1
484711 }
485712
@@ -491,7 +718,7 @@ func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Cal
491718 // GetResponseHeaders sets status code, MIME type, and content length.
492719 // CEF's SetMimeType expects the MIME type without charset parameters;
493720 // 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) {
495722 response.SetStatus(int32(rh.statusCode))
496723 if text := http.StatusText(rh.statusCode); text != "" {
497724 response.SetStatusText(text)
@@ -504,18 +731,20 @@ func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, r
504731 for name, value := range rh.headers {
505732 response.SetHeaderByName(name, value, 1)
506733 }
507- *(*int64)(responseLength) = int64(len(rh.data))
734+ if responseLength != nil {
735+ *responseLength = int64(len(rh.data))
736+ }
508737 }
509738
510739 // 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 {
512741 return 0
513742 }
514743
515744 // Read copies data into the output buffer.
516745 func (rh *staticResourceHandler) Read(
517746 dataOut unsafe.Pointer, bytesToRead int32,
518- bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
747+ bytesRead *int32, _ purecef.ResourceReadCallback,
519748 ) int32 {
520749 if rh.offset >= len(rh.data) {
521750 return 0
@@ -532,12 +761,14 @@ func (rh *staticResourceHandler) Read(
532761 copy(dst, rh.data[rh.offset:rh.offset+toRead])
533762 rh.offset += toRead
534763
535- *(*int32)(bytesRead) = int32(toRead)
764+ if bytesRead != nil {
765+ *bytesRead = int32(toRead)
766+ }
536767 return 1
537768 }
538769
539770 // 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 {
541772 return 0
542773 }
543774
@@ -552,7 +783,7 @@ func (rh *staticResourceHandler) Cancel() {}
552783 // The JS bridge base64-encodes the JSON body into this header to avoid the
553784 // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
554785 func readBodyFromHeader(request purecef.Request) []byte {
555- encoded := request.GetHeaderByName("X-Dumber-Body")
786+ encoded := request.GetHeaderByName(dumberBodyHeaderName)
556787 if encoded == "" {
557788 return nil
558789 }
M internal/infrastructure/cef/scheme_handler_test.go +451 -0
@@ -2,12 +2,28 @@ package cef
22
33 import (
44 "context"
5+ "encoding/base64"
56 "errors"
7+ "net/http"
68 "testing"
79
10+ purecef "github.com/bnema/purego-cef/cef"
11+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
812 "github.com/stretchr/testify/require"
913 )
1014
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+
1127 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
1228 data, err := resolveConfigPayload(func() ([]byte, error) {
1329 return []byte(`{"engine_type":"cef"}`), nil
@@ -43,3 +59,438 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
4359 require.Nil(t, h)
4460 require.Contains(t, err.Error(), "default config payload builder")
4561 }
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 {
4747 // Open starts the transcode session asynchronously. CEF will call
4848 // GetResponseHeaders and Read once the callback fires.
4949 func (rh *transcodingResourceHandler) Open(
50- _ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback,
50+ _ purecef.Request, handleRequest *int32, callback purecef.Callback,
5151 ) int32 {
5252 // Signal async handling: set handleRequest = 0.
53- *(*int32)(handleRequest) = 0
53+ if handleRequest != nil {
54+ *handleRequest = 0
55+ }
5456
5557 log := rh.logger()
5658 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
@@ -84,7 +86,7 @@ func (rh *transcodingResourceHandler) Open(
8486
8587 // GetResponseHeaders sets the streaming response metadata.
8688 func (rh *transcodingResourceHandler) GetResponseHeaders(
87- response purecef.Response, responseLength unsafe.Pointer, _ uintptr,
89+ response purecef.Response, responseLength *int64, _ uintptr,
8890 ) {
8991 response.SetStatus(httpStatusOK)
9092 response.SetStatusText("OK")
@@ -96,13 +98,15 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(
9698 response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1)
9799 response.SetHeaderByName("Cache-Control", "no-store", 1)
98100 // Streaming — unknown length.
99- *(*int64)(responseLength) = -1
101+ if responseLength != nil {
102+ *responseLength = -1
103+ }
100104 }
101105
102106 // Read copies transcoded data from the session into the CEF output buffer.
103107 func (rh *transcodingResourceHandler) Read(
104108 dataOut unsafe.Pointer, bytesToRead int32,
105- bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
109+ bytesRead *int32, _ purecef.ResourceReadCallback,
106110 ) int32 {
107111 if rh.session == nil {
108112 return 0
@@ -118,7 +122,9 @@ func (rh *transcodingResourceHandler) Read(
118122 Int("first_chunk_bytes", n).
119123 Msg("cef: transcoding resource stream produced first bytes")
120124 }
121- *(*int32)(bytesRead) = int32(n)
125+ if bytesRead != nil {
126+ *bytesRead = int32(n)
127+ }
122128 return 1
123129 }
124130 if err != nil {
@@ -166,12 +172,12 @@ func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purece
166172 }
167173
168174 // 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 {
170176 return 0
171177 }
172178
173179 // 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 {
175181 return 0
176182 }
177183
@@ -360,7 +366,7 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(
360366 ) {
361367 }
362368
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) {
364370 }
365371
366372 func buildRequestInfo(request purecef.Request) requestInfo {
M internal/infrastructure/cef/webview.go +979 -29
@@ -2,12 +2,16 @@ package cef
22
33 import (
44 "context"
5+ "crypto/rand"
56 "encoding/base64"
7+ "encoding/hex"
68 "errors"
79 "fmt"
810 "math"
11+ "strings"
912 "sync"
1013 "sync/atomic"
14+ "time"
1115
1216 purecef "github.com/bnema/purego-cef/cef"
1317 "github.com/bnema/puregotk/v4/glib"
@@ -19,9 +23,12 @@ import (
1923
2024 // Compile-time interface checks.
2125 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)
2532 )
2633
2734 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -30,19 +37,47 @@ var errDestroyed = errors.New("cef: webview is destroyed")
3037 // errNoBrowser is returned when the browser has not been created yet.
3138 var errNoBrowser = errors.New("cef: browser not yet created")
3239
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+
3367 // WebView implements port.WebView using a CEF off-screen browser rendered
3468 // through a renderPipeline and driven by an inputBridge.
3569 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
4681
4782 // beginFrameTick drives CEF external BeginFrame requests while the GTK
4883 // widget is visible. Access is guarded by mu.
@@ -63,17 +98,60 @@ type WebView struct {
6398 mu sync.RWMutex
6499 callbacks *port.WebViewCallbacks
65100
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+
66130 // 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
73144
74145 // Last known hover URI for middle-click → new tab.
75146 lastHoverURI string
76147
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+
77155 // Atomic state.
78156 destroyed atomic.Bool
79157 fullscreen atomic.Bool
@@ -81,6 +159,11 @@ type WebView struct {
81159 audioPlaying atomic.Bool
82160 zoomFactor atomic.Value // float64, initialized to 1.0
83161
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+
84167 // Audio output factory and active stream.
85168 audioOutputFactory port.AudioOutputFactory
86169 audioStreamMu sync.Mutex
@@ -94,9 +177,11 @@ type WebView struct {
94177 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
95178 // deferred until the GL area has a non-zero size.
96179 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
100185 }
101186
102187 type cefTaskFunc func()
@@ -126,16 +211,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
126211 actualURI := toActualInternalURL(uri)
127212 wv.mu.Lock()
128213 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()
129218 if browser == nil {
130- // Browser not yet created — queue the URI for OnAfterCreated.
131- wv.pendingURI = actualURI
132- wv.mu.Unlock()
133219 return nil
134220 }
135- wv.mu.Unlock()
136- if frame := browser.GetMainFrame(); frame != nil {
137- frame.LoadURL(actualURI)
138- }
221+ wv.schedulePendingNavigationReplay(0)
139222 return nil
140223 }
141224
@@ -422,6 +505,408 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
422505 wv.callbacks = cb
423506 }
424507
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+
425910 // ---------------------------------------------------------------------------
426911 // JavaScript / Appearance
427912 // ---------------------------------------------------------------------------
@@ -445,6 +930,40 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
445930 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
446931 const colorScale = 255
447932
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+
448967 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
449968 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
450969 script := fmt.Sprintf(
@@ -472,6 +991,27 @@ func (wv *WebView) Destroy() {
472991 if !wv.destroyed.CompareAndSwap(false, true) {
473992 return
474993 }
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+ }
4751015 wv.closeAudioStream()
4761016 wv.scheduleStopBeginFrameLoop()
4771017 wv.mu.RLock()
@@ -479,6 +1019,8 @@ func (wv *WebView) Destroy() {
4791019 wv.mu.RUnlock()
4801020 if host != nil {
4811021 host.CloseBrowser(1)
1022+ } else {
1023+ wv.runCloseCallbacks()
4821024 }
4831025 if wv.pipeline != nil {
4841026 wv.pipeline.destroy()
@@ -503,16 +1045,29 @@ func (wv *WebView) NativeWidget() uintptr {
5031045
5041046 func (wv *WebView) updateURI(uri string) {
5051047 uri = toConceptualInternalURL(uri)
1048+ now := time.Now()
5061049 wv.mu.Lock()
5071050 wv.uri = uri
1051+ wv.loadDiagLastAddressAt = now
5081052 cb := wv.callbacks
1053+ pendingMatched := pendingURIEquivalent(wv.pendingURI, uri)
1054+ if pendingMatched {
1055+ wv.pendingURI = ""
1056+ }
5091057 wv.mu.Unlock()
5101058
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+
5111065 if cb != nil && cb.OnURIChanged != nil {
5121066 wv.runOnGTK(func() {
5131067 cb.OnURIChanged(uri)
5141068 })
5151069 }
1070+ wv.runNavigationCallbacks(uri)
5161071 }
5171072
5181073 func (wv *WebView) updateTitle(title string) {
@@ -529,8 +1084,10 @@ func (wv *WebView) updateTitle(title string) {
5291084 }
5301085
5311086 func (wv *WebView) updateProgress(progress float64) {
1087+ now := time.Now()
5321088 wv.mu.Lock()
5331089 wv.progress = progress
1090+ wv.loadDiagLastProgressAt = now
5341091 cb := wv.callbacks
5351092 wv.mu.Unlock()
5361093
@@ -542,11 +1099,26 @@ func (wv *WebView) updateProgress(progress float64) {
5421099 }
5431100
5441101 func (wv *WebView) updateLoadState(loading, back, fwd bool) {
1102+ now := time.Now()
5451103 wv.mu.Lock()
1104+ wasLoading := wv.isLoading
5461105 wv.isLoading = loading
5471106 wv.canGoBack = back
5481107 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+ }
5491117 wv.mu.Unlock()
1118+
1119+ if loadDiagSeq != 0 {
1120+ wv.scheduleLoadWatchdogs(loadDiagSeq)
1121+ }
5501122 }
5511123
5521124 func (wv *WebView) setAudioPlaying(playing bool) {
@@ -561,6 +1133,320 @@ func (wv *WebView) setAudioPlaying(playing bool) {
5611133 }
5621134 }
5631135
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+
5641450 func (wv *WebView) updateHoverURI(uri string) {
5651451 wv.mu.Lock()
5661452 wv.lastHoverURI = uri
@@ -708,6 +1594,70 @@ func (wv *WebView) runOnGTK(fn func()) {
7081594 glib.IdleAddOnce(cb, 0)
7091595 }
7101596
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+
7111661 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
7121662 wv.mu.Lock()
7131663 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 (
77 purecef "github.com/bnema/purego-cef/cef"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1011 "github.com/bnema/dumber/internal/infrastructure/transcoder"
1112 "github.com/bnema/dumber/internal/logging"
1213 )
@@ -15,28 +16,29 @@ import (
1516 const (
1617 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
1718 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
19+ consoleMarkerAutoCopy = "[AUTO-COPY]"
1820 )
1921
2022 // handlerSet implements all CEF handler interfaces and dispatches events to the
2123 // owning WebView. A single struct is used so that the Client's Get*Handler
2224 // methods can return the same receiver, avoiding extra allocations.
2325 type handlerSet struct {
24- wv *WebView
25- enableContextMenuHandler bool
26- transcodingHandler purecef.ResourceRequestHandler
26+ wv *WebView
27+ transcodingHandler purecef.ResourceRequestHandler
2728 }
2829
2930 // Compile-time interface checks.
3031 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)
4042 )
4143
4244 // ===========================================================================
@@ -50,16 +52,16 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
5052 // audio backend (PipeWire/PulseAudio) is wired up.
5153 return h
5254 }
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 {
5661 return h
5762 }
5863 return nil
5964 }
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 }
6365 func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
6466 func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
6567 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
@@ -67,14 +69,73 @@ func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return n
6769 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
6870 func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
6971 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 }
7173 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
7274 func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
7375 func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
7476 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
7577
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
78139 }
79140
80141 // ===========================================================================
@@ -114,6 +175,7 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
114175 rect.Height = ht
115176 if h.wv != nil && h.wv.ctx != nil {
116177 logging.FromContext(h.wv.ctx).Debug().
178+ Uint64("webview_id", uint64(h.wv.id)).
117179 Uint64("view_rect_seq", callSeq).
118180 Int32("width", rect.Width).
119181 Int32("height", rect.Height).
@@ -155,6 +217,7 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
155217 *info = si
156218 if h.wv != nil && h.wv.ctx != nil {
157219 logging.FromContext(h.wv.ctx).Debug().
220+ Uint64("webview_id", uint64(h.wv.id)).
158221 Uint64("screen_info_seq", callSeq).
159222 Int32("width", w).
160223 Int32("height", ht).
@@ -201,14 +264,27 @@ func (h *handlerSet) OnPaint(
201264 return
202265 }
203266 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+ }
204271 if h.wv != nil && h.wv.ctx != nil {
205- logging.FromContext(h.wv.ctx).Trace().
272+ log := logging.FromContext(h.wv.ctx).Trace().
206273 Uint64("paint_seq", paintSeq).
207274 Int32("width", width).
208275 Int32("height", height).
209276 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
212288 }
213289 rects := make([]rect, len(dirtyRects))
214290 for i, dr := range dirtyRects {
@@ -240,7 +316,59 @@ func (h *handlerSet) OnPaint(
240316 }
241317 }
242318
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+) {
244372 count := h.wv.pipeline.recordAcceleratedPaint()
245373 if count <= 5 || count%100 == 0 {
246374 logging.FromContext(h.wv.ctx).Info().
@@ -254,7 +382,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA
254382
255383 func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}
256384
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 {
258391 return 0
259392 }
260393
@@ -265,7 +398,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {}
265398 func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {
266399 }
267400
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+}
269424
270425 func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}
271426
@@ -276,6 +431,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex
276431 // OnAddressChange updates the cached URI when the main frame navigates.
277432 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
278433 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+ }
279439 h.wv.updateURI(url)
280440 }
281441 }
@@ -285,7 +445,7 @@ func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
285445 h.wv.updateTitle(title)
286446 }
287447
288-func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ uintptr) {}
448+func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ purecef.StringList) {}
289449
290450 // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
291451 func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
@@ -327,9 +487,16 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
327487 }
328488 }
329489
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 {
331496 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)) {
333500 log := logging.FromContext(h.wv.ctx).With().
334501 Str("component", "cef-console").
335502 Str("source", source).
@@ -351,10 +518,20 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re
351518
352519 // OnLoadingProgressChange updates the cached progress value.
353520 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+ }
354526 h.wv.updateProgress(progress)
355527 }
356528
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 {
358535 name := cefCursorToGDKName(cursorType)
359536 h.wv.runOnGTK(func() {
360537 h.wv.pipeline.glArea.SetCursorFromName(&name)
@@ -376,6 +553,30 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
376553 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
377554 loading := isloading != 0
378555 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+ }
379580
380581 h.wv.mu.RLock()
381582 cb := h.wv.callbacks
@@ -400,6 +601,25 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
400601 if frame == nil || !frame.IsMain() {
401602 return
402603 }
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+ }
403623 h.wv.updateURI(frame.GetURL())
404624 h.wv.mu.RLock()
405625 cb := h.wv.callbacks
@@ -425,6 +645,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
425645 // Successful load — reset the consecutive crash counter.
426646 h.wv.crashCount.Store(0)
427647
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+
428660 // Inject scripts and styles after page load.
429661 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
430662 // and JavaScript injection requires the main thread.
@@ -454,13 +686,15 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
454686 // ===========================================================================
455687
456688 // 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.
459693 func (h *handlerSet) OnBeforePopup(
460- _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
694+ _ purecef.Browser, _ purecef.Frame, popupID int32, targetURL, targetFrameName string,
461695 _ 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,
464698 ) bool {
465699 if targetURL == "" {
466700 return true
@@ -469,26 +703,50 @@ func (h *handlerSet) OnBeforePopup(
469703 h.wv.mu.RLock()
470704 cb := h.wv.callbacks
471705 h.wv.mu.RUnlock()
706+ if cb == nil || cb.OnCreate == nil {
707+ return true
708+ }
472709
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,
482722 })
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
483732 }
484733
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
486740 }
487741
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+}
489747
490748 func (h *handlerSet) OnBeforeDevToolsPopup(
491- _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client,
749+ _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
492750 _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
493751 ) {
494752 }
@@ -501,46 +759,112 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
501759 return
502760 }
503761 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+
512764 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+ }
513779
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{}
514789 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+
515801 h.wv.browser = browser
516802 h.wv.host = host
517- uri := h.wv.pendingURI
518- h.wv.pendingURI = ""
803+ h.wv.pendingCreate = nil
519804 h.wv.input.setHost(host)
520805 if h.wv.findCtrl != nil {
521806 h.wv.findCtrl.setHost(host)
522807 }
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
523813
524814 // 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.
526816 host.WasHidden(0)
817+ state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
818+ state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
819+ return state
820+}
527821
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
533825 }
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
540828 }
541- h.wv.mu.Unlock()
829+ return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus()
830+}
542831
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+ }
543857 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
544868 }
545869
546870 // DoClose returns false to allow the default close behavior.
@@ -549,9 +873,13 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
549873 }
550874
551875 // OnBeforeClose fires the OnClose callback.
552-func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
876+func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
553877 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)
555883 }
556884 h.wv.mu.Lock()
557885 h.wv.browser = nil
@@ -561,6 +889,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
561889 h.wv.findCtrl.setHost(nil)
562890 }
563891 h.wv.mu.Unlock()
892+ h.wv.resizeReconciler.stop()
564893 h.wv.scheduleStopBeginFrameLoop()
565894
566895 h.wv.mu.RLock()
@@ -571,17 +900,95 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
571900 cb.OnClose()
572901 })
573902 }
903+ h.wv.runCloseCallbacks()
574904 }
575905
576906 // ===========================================================================
577907 // RequestHandler (11 methods)
578908 // ===========================================================================
579909
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)
582944 }
583945
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 {
585992 return 0
586993 }
587994
@@ -589,7 +996,8 @@ func (h *handlerSet) GetResourceRequestHandler(
589996 _ purecef.Browser, _ purecef.Frame, request purecef.Request,
590997 _, _ int32, _ string, disableDefaultHandling *int32,
591998 ) 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()) {
5931001 *disableDefaultHandling = 1
5941002 if h.wv != nil && h.wv.ctx != nil {
5951003 logging.FromContext(h.wv.ctx).Info().
@@ -610,7 +1018,13 @@ func (h *handlerSet) GetAuthCredentials(
6101018 return 0
6111019 }
6121020
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 {
6141028 return 0
6151029 }
6161030
@@ -839,7 +1253,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
8391253 // ===========================================================================
8401254
8411255 // 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+) {
8431262 if h.wv.findCtrl != nil {
8441263 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
8451264 }
M internal/infrastructure/cef/webview_handlers_test.go +449 -4
@@ -2,15 +2,64 @@ package cef
22
33 import (
44 "context"
5+ "sync"
56 "testing"
7+ "time"
68
79 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
811 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
913 "github.com/stretchr/testify/require"
1014
15+ "github.com/bnema/dumber/internal/application/dto"
1116 "github.com/bnema/dumber/internal/application/port"
17+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1218 )
1319
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+
1463 func newTestPipeline(w, h, s int32) *renderPipeline {
1564 rp := &renderPipeline{scale: s}
1665 rp.widthAtomic.Store(w)
@@ -52,6 +101,159 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
52101 return nil
53102 }
54103
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+
55257 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
56258 wv := &WebView{ctx: context.Background()}
57259 var gotEvents []port.LoadEvent
@@ -89,6 +291,215 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
89291 assert.Empty(t, gotProgress)
90292 }
91293
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+
92503 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
93504 rect := &purecef.Rect{}
94505 h := &handlerSet{
@@ -126,14 +537,48 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
126537 require.Equal(t, int32(300), info.AvailableRect.Height)
127538 }
128539
129-func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
540+func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
130541 h := &handlerSet{}
131542
132543 // AudioHandler is always enabled (required for media decoding).
133544 require.Same(t, h, h.GetAudioHandler())
134- require.Nil(t, h.GetContextMenuHandler())
545+ require.Same(t, h, h.GetContextMenuHandler())
546+}
135547
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{}))
137551
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)
139584 }
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.
22 package clipboard
33
44 import (
5+ "bytes"
56 "context"
67 "fmt"
78 "os"
@@ -9,49 +10,266 @@ import (
910 "strings"
1011
1112 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
1214 "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"
1319 )
1420
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.
1729 type Adapter struct {
30+ toolkit toolkitClipboard
1831 copyCmd string
1932 pasteCmd string
2033 }
2134
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+
2251 // New creates a new clipboard adapter.
2352 // Detects Wayland vs X11 and selects appropriate clipboard tool.
2453 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+ }
26113
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.
28171 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
33176 }
34177 }
35178 }
36179
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
45188 }
46189 }
47190
48- return a
191+ return copyCmd, pasteCmd
49192 }
50193
51194 // WriteText copies text to the clipboard.
52195 func (a *Adapter) WriteText(ctx context.Context, text string) error {
53196 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+}
54271
272+func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
55273 if a.copyCmd == "" {
56274 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
57275 log.Error().Err(err).Msg("clipboard write failed")
@@ -60,11 +278,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
60278
61279 var cmd *exec.Cmd
62280 if strings.Contains(a.copyCmd, "wl-copy") {
63- cmd = exec.CommandContext(ctx, a.copyCmd)
281+ cmd = commandContext(ctx, a.copyCmd)
64282 } else if strings.Contains(a.copyCmd, "xclip") {
65- cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard")
283+ cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
66284 } else if strings.Contains(a.copyCmd, "xsel") {
67- cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input")
285+ cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
68286 } else {
69287 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
70288 log.Error().Err(err).Msg("clipboard write failed")
@@ -73,6 +291,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
73291
74292 cmd.Stdin = strings.NewReader(text)
75293 if err := cmd.Run(); err != nil {
294+ err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
76295 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
77296 return err
78297 }
@@ -81,10 +300,40 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
81300 return nil
82301 }
83302
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+ }
87331
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) {
88337 if a.pasteCmd == "" {
89338 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
90339 log.Error().Err(err).Msg("clipboard read failed")
@@ -93,11 +342,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
93342
94343 var cmd *exec.Cmd
95344 if strings.Contains(a.pasteCmd, "wl-paste") {
96- cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline")
345+ cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
97346 } 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")
99348 } else if strings.Contains(a.pasteCmd, "xsel") {
100- cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output")
349+ cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
101350 } else {
102351 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
103352 log.Error().Err(err).Msg("clipboard read failed")
@@ -106,7 +355,8 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
106355
107356 out, err := cmd.Output()
108357 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")
110360 return "", err
111361 }
112362
@@ -114,6 +364,41 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
114364 return string(out), nil
115365 }
116366
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+
117402 // Clear clears the clipboard contents.
118403 func (a *Adapter) Clear(ctx context.Context) error {
119404 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 (
2020
2121 // Omnibox defaults
2222 defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent
23+ defaultOmniboxMostVisitedDays = 30
2324 defaultOmniboxAutoOpenOnNewPane = false
2425
2526 // Workspace defaults
@@ -284,6 +285,7 @@ func DefaultConfig() *Config {
284285 },
285286 Omnibox: OmniboxConfig{
286287 InitialBehavior: defaultOmniboxInitialBehavior,
288+ MostVisitedDays: defaultOmniboxMostVisitedDays,
287289 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
288290 },
289291 Session: SessionConfig{
M internal/infrastructure/config/defaults_test.go +0 -1
@@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2323 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
2424 assert.Empty(t, cfg.Engine.CEF.LogFile)
2525 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
26- assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
2726 assert.False(t, cfg.Engine.CEF.TraceHandlers)
2827 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
2928
M internal/infrastructure/config/engine.go +1 -3
@@ -85,7 +85,7 @@ type CEFEngineConfig struct {
8585 // libcef.so and the Resources/locales subdirectories.
8686 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
8787 // 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.
8989 LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
9090 // LogSeverity controls CEF's internal log verbosity.
9191 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
@@ -95,8 +95,6 @@ type CEFEngineConfig struct {
9595 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
9696 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
9797 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
10098 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
10199 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
102100 }
M internal/infrastructure/config/loader.go +6 -5
@@ -136,18 +136,19 @@ func (m *Manager) readConfigFile() error {
136136 }
137137 }
138138
139- // Transform legacy action bindings before unmarshaling
140- m.transformLegacyActionBindings()
139+ // Transform legacy action bindings and engine config before unmarshaling.
140+ m.transformLegacyConfig()
141141
142142 return nil
143143 }
144144
145-// transformLegacyActionBindings converts old-format action bindings to new format.
145+// transformLegacyConfig converts old-format action bindings and engine config to the new format.
146146 // This is called after reading config but before unmarshaling.
147-func (m *Manager) transformLegacyActionBindings() {
147+func (m *Manager) transformLegacyConfig() {
148148 rawConfig := m.viper.AllSettings()
149149 transformer := NewLegacyConfigTransformer()
150150 transformer.TransformLegacyActions(rawConfig)
151+ transformer.TransformLegacyEngineConfig(rawConfig)
151152
152153 // Apply transformed config back to viper
153154 for key, value := range rawConfig {
@@ -526,6 +527,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) {
526527
527528 func (m *Manager) setOmniboxDefaults(defaults *Config) {
528529 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
530+ m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)
529531 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
530532 }
531533
@@ -575,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
575577 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
576578 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
577579 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
578- m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler)
579580 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
580581
581582 wk := e.WebKit
M internal/infrastructure/config/migrate.go +2 -0
@@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) {
380380 // Transform legacy action bindings (slice -> ActionBinding struct)
381381 transformer := NewLegacyConfigTransformer()
382382 transformer.TransformLegacyActions(rawConfig)
383+ transformer.TransformLegacyEngineConfig(rawConfig)
383384
384385 // Build sets of keys to remove and renames to apply
385386 keysToRemove := make(map[string]bool)
@@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an
670671 // Transform legacy action bindings before flattening
671672 transformer := NewLegacyConfigTransformer()
672673 transformer.TransformLegacyActions(rawConfig)
674+ transformer.TransformLegacyEngineConfig(rawConfig)
673675
674676 // Flatten the map to dot-notation keys with values
675677 result := make(map[string]any)
M internal/infrastructure/config/schema.go +4 -0
@@ -359,6 +359,10 @@ type OmniboxConfig struct {
359359 // InitialBehavior controls what to show when omnibox opens with empty input
360360 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
361361 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"`
362366 // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.
363367 // Default: false
364368 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 {
672672 },
673673 Section: SectionOmnibox,
674674 },
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+ },
675683 {
676684 Key: "omnibox.auto_open_on_new_pane",
677685 Type: "bool",
@@ -879,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
879887 Description: "Enable the experimental CEF audio handler bridge",
880888 Section: SectionDebug,
881889 },
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- },
889890 }
890891 }
891892
M internal/infrastructure/config/transform_legacy.go +15 -0
@@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an
4141 }
4242 }
4343
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+
4459 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
4560 // Navigate to the parent of the actions section
4661 current := rawConfig
M internal/infrastructure/config/transform_legacy_test.go +63 -0
@@ -170,6 +170,69 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) {
170170 require.NotNil(t, rawConfig["workspace"])
171171 }
172172
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+
173236 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
174237 transformer := NewLegacyConfigTransformer()
175238
M internal/infrastructure/config/validation.go +7 -3
@@ -388,18 +388,22 @@ func validateWorkspaceURLValue(fieldPath, value string) []string {
388388 }
389389
390390 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+ }
391395 switch config.Omnibox.InitialBehavior {
392396 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
393- return nil
394397 default:
395- return []string{fmt.Sprintf(
398+ validationErrors = append(validationErrors, fmt.Sprintf(
396399 "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)",
397400 OmniboxInitialBehaviorRecent,
398401 OmniboxInitialBehaviorMostVisited,
399402 OmniboxInitialBehaviorNone,
400403 config.Omnibox.InitialBehavior,
401- )}
404+ ))
402405 }
406+ return validationErrors
403407 }
404408
405409 func validateRendering(config *Config) []string {
M internal/infrastructure/config/xdg.go +39 -25
@@ -4,6 +4,8 @@ package config
44 import (
55 "os"
66 "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
79 )
810
911 // File permission constants
@@ -25,25 +27,42 @@ type XDGDirs struct {
2527 CacheHome string
2628 }
2729
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}.
3333 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) {
3564 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
4766 }
4867
4968 homeDir, err := os.UserHomeDir()
@@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) {
5170 return nil, err
5271 }
5372
54- // XDG_CONFIG_HOME
5573 configHome := os.Getenv("XDG_CONFIG_HOME")
5674 if configHome == "" {
5775 configHome = filepath.Join(homeDir, ".config")
5876 }
5977 configHome = filepath.Join(configHome, appName)
6078
61- // XDG_DATA_HOME
6279 dataHome := os.Getenv("XDG_DATA_HOME")
6380 if dataHome == "" {
6481 dataHome = filepath.Join(homeDir, ".local", "share")
6582 }
6683 dataHome = filepath.Join(dataHome, appName)
6784
68- // XDG_STATE_HOME
6985 stateHome := os.Getenv("XDG_STATE_HOME")
7086 if stateHome == "" {
7187 stateHome = filepath.Join(homeDir, ".local", "state")
7288 }
7389 stateHome = filepath.Join(stateHome, appName)
7490
75- // XDG_CACHE_HOME
7691 cacheHome := os.Getenv("XDG_CACHE_HOME")
7792 if cacheHome == "" {
7893 cacheHome = filepath.Join(homeDir, ".cache")
@@ -114,14 +129,13 @@ func GetStateDir() (string, error) {
114129 return dirs.StateHome, nil
115130 }
116131
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.
119133 func GetLogDir() (string, error) {
120- stateDir, err := GetStateDir()
134+ profile, err := resolveSharedProfile()
121135 if err != nil {
122136 return "", err
123137 }
124- return filepath.Join(stateDir, "logs"), nil
138+ return profile.Shared.LogDir, nil
125139 }
126140
127141 // 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 {
381381
382382 // SessionSpawner implements port.SessionSpawner by launching a new dumber process.
383383 type SessionSpawner struct {
384- ctx context.Context
384+ ctx context.Context
385+ spawnEnv port.SessionSpawnEnvironment
385386 }
386387
387388 // 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}
390391 }
391392
392393 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
@@ -406,7 +407,18 @@ func LaunchExternalURL(uri string) {
406407
407408 // LaunchBrowserURL opens a URL in a new dumber browser window.
408409 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()
410422 if err != nil {
411423 fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err)
412424 return
@@ -414,7 +426,7 @@ func LaunchBrowserURL(uri string) {
414426
415427 cmd := exec.Command(execPath, "browse", uri)
416428 cmd.Env = sanitizedChildEnv(os.Environ())
417- if err := startDetachedProcess(cmd); err != nil {
429+ if err := spawnDetachedProcess(cmd); err != nil {
418430 fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err)
419431 }
420432 }
@@ -469,6 +481,33 @@ func sanitizedChildEnv(env []string) []string {
469481 return cleaned
470482 }
471483
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+
472511 // SpawnWithSession starts a new dumber instance to restore a session.
473512 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
474513 log := logging.FromContext(s.ctx)
@@ -481,8 +520,14 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
481520 // Start dumber browse with session ID in environment
482521 cmd := exec.Command(execPath, "browse")
483522
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
486531
487532 // Detach from current process group so the new process survives.
488533 cmd.Stdin = nil
M internal/infrastructure/desktop/adapter_test.go +149 -1
@@ -1,6 +1,70 @@
11 package desktop
22
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+}
468
569 func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
670 env := sanitizedChildEnv([]string{
@@ -32,3 +96,87 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
3296 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
3397 }
3498 }
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 (
44 "context"
55 "time"
66
7- "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/bnema/puregotk/v4/webkit"
88 )
99
1010 // FilterStore abstracts the WebKit UserContentFilterStore operations.
M internal/infrastructure/filtering/manager.go +1 -1
@@ -14,7 +14,7 @@ import (
1414
1515 "github.com/bnema/dumber/internal/application/port"
1616 "github.com/bnema/dumber/internal/logging"
17- "github.com/bnema/puregotk-webkit/webkit"
17+ "github.com/bnema/puregotk/v4/webkit"
1818 )
1919
2020 // Compile-time check that Manager satisfies port.FilterManager.
M internal/infrastructure/filtering/manager_test.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/infrastructure/filtering"
1414 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"
1515 "github.com/bnema/dumber/internal/logging"
16- "github.com/bnema/puregotk-webkit/webkit"
16+ "github.com/bnema/puregotk/v4/webkit"
1717 "github.com/stretchr/testify/assert"
1818 "github.com/stretchr/testify/mock"
1919 "github.com/stretchr/testify/require"
M internal/infrastructure/filtering/mocks/mocks.go +1 -1
@@ -9,7 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/dumber/internal/infrastructure/filtering"
12- "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/bnema/puregotk/v4/webkit"
1313 mock "github.com/stretchr/testify/mock"
1414 )
1515
M internal/infrastructure/filtering/store.go +1 -1
@@ -6,8 +6,8 @@ import (
66 "sync"
77
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
109 "github.com/bnema/puregotk/v4/gio"
10+ "github.com/bnema/puregotk/v4/webkit"
1111 )
1212
1313 // 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 := &gtk.Widget{}
13+ parent := &gtk.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 (
44 "context"
55 "encoding/json"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
910 )
1011
1112 // ClipboardHandler handles clipboard-related messages from webviews.
1213 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
1615 }
1716
1817 // 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 {
2019 return &ClipboardHandler{
21- clipboard: clipboard,
22- autoCopyConfig: autoCopyConfig,
23- onCopied: onCopied,
20+ orchestrator: orchestrator,
2421 }
2522 }
2623
@@ -29,38 +26,58 @@ type autoCopyRequest struct {
2926 Text string `json:"text"`
3027 }
3128
29+type explicitCopyRequest struct {
30+ Text string `json:"text"`
31+ Action string `json:"action"`
32+}
33+
3234 // HandleAutoCopySelection handles the auto_copy_selection message from JS.
3335 // It copies the selected text to the clipboard if the feature is enabled.
3436 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) {
3638 log := logging.FromContext(ctx)
3739
38- // Check if feature is enabled
39- if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() {
40- return nil, nil
41- }
42-
4340 var req autoCopyRequest
4441 if err := json.Unmarshal(payload, &req); err != nil {
4542 log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
4643 return nil, nil // Silently ignore malformed requests
4744 }
48-
49- // Skip empty or very short selections (< 2 chars)
50- if len(req.Text) < 2 {
45+ if h.orchestrator == nil {
5146 return nil, nil
5247 }
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")
5754 }
5855
59- log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
56+ return nil, nil
57+ })
58+}
6059
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")
6481 }
6582
6683 return nil, nil
@@ -71,16 +88,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
7188 func RegisterClipboardHandlers(
7289 ctx context.Context,
7390 router port.WebUIHandlerRouter,
74- clipboard port.Clipboard,
75- autoCopyConfig port.AutoCopyConfig,
76- onCopied func(textLen int),
91+ orchestrator port.ClipboardTextOrchestrator,
7792 ) error {
78- handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied)
93+ handler := NewClipboardHandler(orchestrator)
7994
8095 // Register auto_copy_selection handler
8196 if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
8297 return err
8398 }
99+ if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil {
100+ return err
101+ }
84102
85103 log := logging.FromContext(ctx)
86104 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.
5252 }
5353
5454 // 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 {
5757 return err
5858 }
5959 }
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 (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/bnema/puregotk/v4/webkit"
1010 )
1111
1212 // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
M internal/infrastructure/transcoder/pipeline.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 "net"
1414 "time"
1515
16- "github.com/ebitengine/purego"
16+ "github.com/bnema/purego"
1717 "github.com/rs/zerolog"
1818
1919 "github.com/bnema/purego-ffmpeg/ffmpeg"
M internal/infrastructure/webkit/context.go +2 -2
@@ -7,12 +7,12 @@ import (
77 "sync"
88
99 "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/puregotk-webkit/webkit"
10+ "github.com/bnema/puregotk/v4/webkit"
1111 "github.com/rs/zerolog"
1212 )
1313
1414 // 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.
1616 type FaviconDatabase = webkit.FaviconDatabase
1717
1818 // 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
33 import (
44 "context"
55 "fmt"
6- "os"
76 "sync"
87
98 "github.com/bnema/dumber/internal/application/port"
109 downloadutil "github.com/bnema/dumber/internal/domain/download"
10+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
1111 "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk-webkit/webkit"
1312 "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"
1914 )
2015
2116 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2217 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
2720 }
2821
2922 // 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.
3124 func NewDownloadHandler(
3225 downloadPath string,
3326 handler port.DownloadEventHandler,
3427 preparer port.DownloadPreparer,
3528 ) *DownloadHandler {
36- if preparer == nil {
37- panic("preparer is required")
38- }
3929 return &DownloadHandler{
40- downloadPath: downloadPath,
41- eventHandler: handler,
42- preparer: preparer,
30+ runtime: downloadruntime.New(downloadPath, handler, preparer),
4331 }
4432 }
4533
4634 // downloadState tracks the state of a single download to prevent duplicate notifications.
4735 // This is allocated per-download to ensure proper isolation between concurrent downloads.
4836 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
5142 }
5243
5344 // HandleDownload sets up signal handlers for a new download.
5445 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
55- log := logging.FromContext(ctx)
46+ if download == nil {
47+ return
48+ }
5649
5750 h.mu.RLock()
58- downloadPath := h.downloadPath
59- eventHandler := h.eventHandler
60- preparer := h.preparer
51+ runtime := h.runtime
6152 h.mu.RUnlock()
6253
6354 // Track download state in a dedicated struct to ensure proper isolation.
6455 // 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)
7677
77- // Wrap WebKit response as port.DownloadResponse
78+ return func(d webkit.Download, suggestedFilename string) bool {
7879 var response port.DownloadResponse
7980 if resp := d.GetResponse(); resp != nil {
8081 response = &uriResponseAdapter{resp: resp}
8182 }
8283
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+ }
8990
9091 log.Debug().
9192 Str("suggested", suggestedFilename).
@@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
9394 Str("destPath", output.DestinationPath).
9495 Msg("setting download destination")
9596
96- // WebKit expects an absolute file path for the destination.
9797 d.SetDestination(output.DestinationPath)
9898
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()
107104
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+}
112110
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)
114118 }
115- download.ConnectDecideDestination(&decideDestCb)
119+}
116120
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) {
119127 state.mu.Lock()
120128 state.failed = true
121129 state.mu.Unlock()
122130
123131 dest := d.GetDestination()
124132 filename := downloadutil.ExtractFilenameFromDestination(dest)
125-
126- // Create error for the failed download.
127133 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)
139135 }
140- download.ConnectFailed(&failedCb)
136+}
141137
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) {
144144 state.mu.Lock()
145145 failed := state.failed
146146 state.mu.Unlock()
147-
148- // Don't send finished event if we already sent a failed event.
149147 if failed {
150148 return
151149 }
152150
153151 dest := d.GetDestination()
154152 filename := downloadutil.ExtractFilenameFromDestination(dest)
153+ runtime.EmitFinished(ctx, filename, dest)
154+ }
155+}
155156
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+ }
163175
164- log.Info().Str("filename", filename).Msg("download finished")
176+ progress := d.GetEstimatedProgress()
177+ if progress <= 0 || progress >= 1 {
178+ return
165179 }
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)
167194 }
168195
169196 // SetDownloadPath updates the download directory.
170197 func (h *DownloadHandler) SetDownloadPath(path string) {
171198 h.mu.Lock()
172199 defer h.mu.Unlock()
173- h.downloadPath = path
200+ h.runtime.SetDownloadPath(path)
174201 }
175202
176203 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
M internal/infrastructure/webkit/download_handler_test.go +11 -6
@@ -1,10 +1,13 @@
11 package webkit
22
33 import (
4+ "context"
5+ "path/filepath"
46 "testing"
57
68 "github.com/bnema/dumber/internal/application/usecase"
79 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
811 )
912
1013 func TestNewDownloadHandler(t *testing.T) {
@@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) {
1417 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
1518
1619 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)
2021 })
2122
2223 t.Run("creates handler with custom path", func(t *testing.T) {
2324 handler := NewDownloadHandler("/custom/path", nil, prepareUC)
2425
25- assert.Equal(t, "/custom/path", handler.downloadPath)
26+ require.NotNil(t, handler.runtime)
2627 })
2728 }
2829
@@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) {
3031 prepareUC := usecase.NewPrepareDownloadUseCase(nil)
3132 handler := NewDownloadHandler("/initial/path", nil, prepareUC)
3233
33- handler.SetDownloadPath("/new/path")
34+ newPath := t.TempDir()
35+ handler.SetDownloadPath(newPath)
3436
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)
3641 }
3742
3843 func TestURIResponseAdapter(t *testing.T) {
M internal/infrastructure/webkit/engine.go +65 -11
@@ -13,17 +13,23 @@ import (
1313
1414 // Engine implements port.Engine for the WebKit browser engine.
1515 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
2733 }
2834
2935 // Compile-time check that Engine implements port.Engine.
@@ -92,6 +98,9 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
9298 if e.messageRouter == nil {
9399 return fmt.Errorf("message router not initialized")
94100 }
101+ // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
102+ e.clipboard = deps.Clipboard
103+ e.onClipboardCopied = deps.OnClipboardCopied
95104 return handlers.RegisterAll(ctx, e.messageRouter, deps)
96105 }
97106
@@ -111,8 +120,19 @@ func (e *Engine) ConfigureDownloads(
111120 if e.wkCtx == nil {
112121 return fmt.Errorf("webkit context not initialized")
113122 }
123+ if preparer == nil {
124+ return fmt.Errorf("download preparer is required")
125+ }
114126 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
115127 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+
116136 return nil
117137 }
118138
@@ -146,3 +166,37 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU
146166 }
147167 return nil
148168 }
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 (
1010 "github.com/bnema/dumber/internal/infrastructure/config"
1111 "github.com/bnema/dumber/internal/infrastructure/env"
1212 "github.com/bnema/dumber/internal/infrastructure/filtering"
13+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1314 "github.com/bnema/dumber/internal/logging"
1415 "github.com/bnema/dumber/internal/ui/theme"
1516 "github.com/bnema/puregotk/v4/gdk"
@@ -22,11 +23,14 @@ func NewEngine(
2223 ctx context.Context,
2324 cfg *config.Config,
2425 opts port.EngineOptions,
26+ profile runtimeprofile.Profile,
2527 wkCfg WebKitEngineConfig,
2628 currentConfigPayload func() ([]byte, error),
2729 defaultConfigPayload func() ([]byte, error),
2830 themeManager *theme.Manager,
2931 colorResolver port.ColorSchemeResolver,
32+ contextMenuBuilder port.ContextMenuBuilder,
33+ contextMenuExecutorFactory port.ContextMenuActionExecutorFactory,
3034 logger zerolog.Logger,
3135 ) (*Engine, error) {
3236 // --- Hardware survey and performance profile resolution ---
@@ -37,7 +41,7 @@ func NewEngine(
3741 logging.Trace().Mark("render_env")
3842
3943 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
40- wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings)
44+ wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
4145 logger.Info().
4246 Str("cookie_policy", string(wkOpts.CookiePolicy)).
4347 Bool("itp_enabled", wkOpts.ITPEnabled).
@@ -50,7 +54,7 @@ func NewEngine(
5054 logging.Trace().Mark("webkit_context")
5155
5256 // --- Filter manager ---
53- filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger)
57+ filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
5458
5559 // --- Scheme handler ---
5660 schemeHandler := NewDumbSchemeHandler(ctx)
@@ -107,17 +111,19 @@ func NewEngine(
107111
108112 // --- Assemble Engine ---
109113 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,
121127 }
122128
123129 return engine, nil
@@ -202,14 +208,24 @@ func engineConfigureRenderingEnvironment(
202208 // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings.
203209 func engineBuildContextOptions(
204210 opts port.EngineOptions,
211+ profile runtimeprofile.Profile,
205212 wkCfg WebKitEngineConfig,
206213 perfSettings *config.ResolvedPerformanceSettings,
207214 ) webKitContextOptions {
208215 cp := opts.CookiePolicy // empty preserves runtime default per port contract
209216
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+
210226 wkOpts := webKitContextOptions{
211- DataDir: opts.DataDir,
212- CacheDir: opts.CacheDir,
227+ DataDir: dataDir,
228+ CacheDir: cacheDir,
213229 CookiePolicy: cp,
214230 ITPEnabled: wkCfg.ITPEnabled,
215231 }
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) {
4646 require.Contains(t, err.Error(), "webkit context not initialized")
4747 }
4848
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+
4957 func TestEngine_Close_NilPool(t *testing.T) {
5058 e := &Engine{}
5159 err := e.Close()
M internal/infrastructure/webkit/factory.go +21 -1
@@ -17,7 +17,8 @@ type WebViewFactory struct {
1717 pool *WebViewPool
1818 injector *ContentInjector
1919 router *MessageRouter
20- filterApplier FilterApplier // Optional content filter applier
20+ filterApplier FilterApplier // Optional content filter applier
21+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
2122
2223 // Background color for WebViews (eliminates white flash)
2324 bg bgColor
@@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
6162 }
6263 }
6364
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+
6474 // Create creates a new WebView instance.
6575 // If a pool is configured, it will try to acquire from the pool first.
6676 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -119,6 +129,11 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
119129 f.filterApplier.ApplyTo(ctx, wv.ucm)
120130 }
121131
132+ // Wire context menu if configured
133+ if f.ctxMenu != nil {
134+ wv.connectContextMenuSignal(f.ctxMenu)
135+ }
136+
122137 log.Debug().
123138 Uint64("id", uint64(wv.ID())).
124139 Uint64("parent_id", uint64(parentID)).
@@ -152,6 +167,11 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
152167 f.filterApplier.ApplyTo(ctx, wv.ucm)
153168 }
154169
170+ // Wire context menu if configured
171+ if f.ctxMenu != nil {
172+ wv.connectContextMenuSignal(f.ctxMenu)
173+ }
174+
155175 log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
156176 return wv, nil
157177 }
M internal/infrastructure/webkit/gobject_type_helpers.go +1 -1
@@ -5,9 +5,9 @@ import (
55 "sync"
66
77 "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/purego"
89 "github.com/bnema/puregotk/pkg/core"
910 gtypes "github.com/bnema/puregotk/v4/gobject/types"
10- "github.com/ebitengine/purego"
1111 )
1212
1313 var (
M internal/infrastructure/webkit/injector.go +143 -7
@@ -10,7 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/infrastructure/webutil"
1212 "github.com/bnema/dumber/internal/logging"
13- "github.com/bnema/puregotk-webkit/webkit"
13+ "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
1616 const (
@@ -59,10 +59,134 @@ const autoCopySelectionScript = `(function() {
5959 });
6060 })();`
6161
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+
62182 // accentDetectionScript is built at init from entity.AccentMap so the JS
63183 // filter stays in sync with the Go-side accent table.
64184 var accentDetectionScript string
65185
186+func buildExplicitCopyScript() string {
187+ return explicitCopyScript
188+}
189+
66190 func init() {
67191 // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
68192 keys := make([]string, 0, len(entity.AccentMap))
@@ -229,7 +353,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
229353 "webrtc-compat-shim",
230354 )
231355
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)
233369 if webviewID != 0 {
234370 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
235371 addScript(
@@ -247,7 +383,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
247383 log.Warn().Msg("webview ID is 0, skipping ID injection")
248384 }
249385
250- // 3. Inject dark mode handler for internal pages only
386+ // 4. Inject dark mode handler for internal pages only
251387 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
252388 internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
253389 addScript(
@@ -261,7 +397,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
261397 "internal-dark-mode-handler",
262398 )
263399
264- // 4. Inject theme CSS for internal pages (dumb://* only)
400+ // 5. Inject theme CSS for internal pages (dumb://* only)
265401 if ci.themeCSSVars != "" {
266402 escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
267403 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
@@ -278,7 +414,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
278414 log.Debug().Msg("theme CSS vars injection configured for internal pages")
279415 }
280416
281- // 5. Inject find highlight CSS for all pages
417+ // 6. Inject find highlight CSS for all pages
282418 if ci.findCSS != "" {
283419 stylesheet := webkit.NewUserStyleSheet(
284420 ci.findCSS,
@@ -295,7 +431,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
295431 }
296432 }
297433
298- // 6. Inject auto-copy selection script for all pages (if enabled)
434+ // 7. Inject auto-copy selection script for all pages (if enabled)
299435 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
300436 if autoCopyEnabled {
301437 addScript(
@@ -311,7 +447,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
311447 log.Debug().Msg("auto-copy selection script injected")
312448 }
313449
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).
315451 // JS only reports keydown/keyup events; Go handles timing and picker display.
316452 addScript(
317453 webkit.NewUserScript(
M internal/infrastructure/webkit/injector_accent_test.go +29 -1
@@ -3,7 +3,7 @@ package webkit
33 import (
44 "testing"
55
6- "github.com/bnema/puregotk-webkit/webkit"
6+ "github.com/bnema/puregotk/v4/webkit"
77 "github.com/stretchr/testify/assert"
88 )
99
@@ -30,3 +30,31 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
3030 "accent detection script must register a focusin listener",
3131 )
3232 }
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 (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/logging"
8- "github.com/bnema/puregotk-webkit/webkit"
8+ "github.com/bnema/puregotk/v4/webkit"
99 )
1010
1111 // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK.
M internal/infrastructure/webkit/messaging.go +2 -2
@@ -9,8 +9,8 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 "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"
1414 )
1515
1616 // Message is the JS -> Go message envelope sent via postMessage.
M internal/infrastructure/webkit/pool.go +34 -2
@@ -10,8 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/dumber/internal/logging"
13- "github.com/bnema/puregotk-webkit/webkit"
1413 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/webkit"
1515 )
1616
1717 // ErrPoolClosed is returned when operations are attempted on a closed pool.
@@ -65,9 +65,11 @@ type WebViewPool struct {
6565
6666 pool chan *WebView
6767 poolMu sync.Mutex
68+ ctxMenuMu sync.RWMutex
6869 injector *ContentInjector
6970 router *MessageRouter
70- filterApplier FilterApplier // Optional content filter applier
71+ filterApplier FilterApplier // Optional content filter applier
72+ ctxMenu *contextMenuPipeline // Optional context menu pipeline
7173
7274 // Background color for WebViews (eliminates white flash)
7375 bg bgColor
@@ -145,6 +147,13 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
145147 p.bg.set(r, g, b, a)
146148 }
147149
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+
148157 // Acquire gets a WebView from the pool or creates a new one.
149158 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
150159 log := logging.FromContext(ctx)
@@ -171,6 +180,16 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
171180 if p.filterApplier != nil {
172181 p.filterApplier.ApplyTo(ctx, wv.ucm)
173182 }
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+ }
174193 wv.ReconnectSignals()
175194 p.acquireCount.Add(1)
176195 p.acquirePoolHits.Add(1)
@@ -214,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
214233 return wv, nil
215234 }
216235
236+func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
237+ return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
238+}
239+
217240 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
218241 log := logging.FromContext(ctx)
219242
@@ -237,6 +260,15 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
237260 p.filterApplier.ApplyTo(ctx, wv.ucm)
238261 }
239262
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+
240272 return wv, nil
241273 }
242274
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 (
1313
1414 "github.com/bnema/dumber/internal/infrastructure/webutil"
1515 "github.com/bnema/dumber/internal/logging"
16- "github.com/bnema/puregotk-webkit/soup"
17- "github.com/bnema/puregotk-webkit/webkit"
1816 "github.com/bnema/puregotk/v4/gio"
17+ "github.com/bnema/puregotk/v4/soup"
18+ "github.com/bnema/puregotk/v4/webkit"
1919 "github.com/rs/zerolog"
2020 )
2121
M internal/infrastructure/webkit/settings.go +1 -1
@@ -6,7 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/infrastructure/config"
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/bnema/puregotk/v4/webkit"
1010 "github.com/rs/zerolog"
1111 )
1212
M internal/infrastructure/webkit/webview.go +14 -19
@@ -11,17 +11,18 @@ import (
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
14+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1415 "github.com/bnema/dumber/internal/domain/entity"
1516 urlutil "github.com/bnema/dumber/internal/domain/url"
1617 "github.com/bnema/dumber/internal/infrastructure/desktop"
1718 "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/puregotk-webkit/webkit"
1919 "github.com/bnema/puregotk/v4/gdk"
2020 "github.com/bnema/puregotk/v4/gio"
2121 "github.com/bnema/puregotk/v4/glib"
2222 "github.com/bnema/puregotk/v4/gobject"
2323 gtypes "github.com/bnema/puregotk/v4/gobject/types"
2424 "github.com/bnema/puregotk/v4/gtk"
25+ "github.com/bnema/puregotk/v4/webkit"
2526 "github.com/rs/zerolog"
2627 )
2728
@@ -29,6 +30,7 @@ import (
2930 var _ port.WebView = (*WebView)(nil)
3031 var _ port.DevToolsOpener = (*WebView)(nil)
3132 var _ port.Printer = (*WebView)(nil)
33+var _ port.PopupLifecycleCapable = (*WebView)(nil)
3234 var _ port.OAuthCallbackCapable = (*WebView)(nil)
3335
3436 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -185,6 +187,9 @@ type WebView struct {
185187
186188 backForwardList *webkit.BackForwardList
187189 backForwardListSignalID uintptr
190+
191+ // contextMenu holds the optional context menu pipeline for reconnection.
192+ contextMenu *contextMenuPipeline
188193 }
189194
190195 type runJSErrorStat struct {
@@ -429,6 +434,7 @@ func (wv *WebView) connectSignals() {
429434 wv.connectBackForwardListChangedSignal()
430435 wv.connectWebProcessTerminatedSignal()
431436 wv.connectPermissionRequestSignal()
437+ wv.connectContextMenuSignal(wv.contextMenu)
432438 }
433439
434440 func (wv *WebView) connectLoadChangedSignal() {
@@ -820,22 +826,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
820826 return false
821827 }
822828
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())
839830 }
840831
841832 func (wv *WebView) connectEnterFullscreenSignal() {
@@ -1594,15 +1585,19 @@ func (wv *WebView) Show() {
15941585 wv.inner.SetVisible(true)
15951586 }
15961587
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+
15971592 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1598-// It implements port.PopupCapable.
1593+// It implements port.PopupLifecycleCapable.
15991594 func (wv *WebView) SetOnReadyToShow(fn func()) {
16001595 wv.OnReadyToShow = fn
16011596 }
16021597
16031598 // SetOnClose composes fn with any existing OnClose handler so multiple callers
16041599 // can each register a close callback without overwriting one another.
1605-// It implements port.PopupCapable.
1600+// It implements port.PopupLifecycleCapable.
16061601 func (wv *WebView) SetOnClose(fn func()) {
16071602 existing := wv.OnClose
16081603 if existing == nil {
M internal/infrastructure/webkit/webview_termination_test.go +1 -1
@@ -3,7 +3,7 @@ package webkit
33 import (
44 "testing"
55
6- webkitlib "github.com/bnema/puregotk-webkit/webkit"
6+ webkitlib "github.com/bnema/puregotk/v4/webkit"
77 "github.com/stretchr/testify/assert"
88 )
99
M internal/infrastructure/xdg/adapter.go +23 -3
@@ -9,11 +9,14 @@ import (
99 )
1010
1111 // Adapter implements port.XDGPaths using config.GetXDGDirs().
12-type Adapter struct{}
12+type Adapter struct {
13+ isDev bool
14+ runtimeDir string
15+}
1316
1417 // 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}
1720 }
1821
1922 func (a *Adapter) ConfigDir() (string, error) {
@@ -28,6 +31,23 @@ func (a *Adapter) StateDir() (string, error) {
2831 return config.GetStateDir()
2932 }
3033
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+
3151 func (a *Adapter) CacheDir() (string, error) {
3252 dirs, err := config.GetXDGDirs()
3353 if err != nil {
M internal/infrastructure/xdg/adapter_test.go +37 -24
@@ -10,20 +10,10 @@ import (
1010 )
1111
1212 func TestAdapter_DownloadDir(t *testing.T) {
13- adapter := New()
13+ adapter := New(false, "")
1414
1515 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")
2717
2818 dir, err := adapter.DownloadDir()
2919
@@ -32,17 +22,7 @@ func TestAdapter_DownloadDir(t *testing.T) {
3222 })
3323
3424 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", "")
4626
4727 dir, err := adapter.DownloadDir()
4828
@@ -56,8 +36,41 @@ func TestAdapter_DownloadDir(t *testing.T) {
5636 })
5737 }
5838
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+
5972 func TestNew(t *testing.T) {
60- adapter := New()
73+ adapter := New(false, "")
6174
6275 assert.NotNil(t, adapter)
6376 }
M internal/ui/app.go +956 -460
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "io"
78 "os"
89 "path/filepath"
910 "strings"
@@ -36,14 +37,17 @@ import (
3637
3738 const (
3839 // AppID is the application identifier for GTK.
39- AppID = "com.github.bnema.dumber"
40+ AppID = "com.github.bnema.dumber"
41+ appTitle = "Dumber"
4042 // 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"
4751 )
4852
4953 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -65,6 +69,12 @@ type App struct {
6569 gtkApp *gtk.Application
6670 mainWindow *window.MainWindow
6771
72+ browserWindows map[string]*browserWindow
73+ lastFocusedWindowID string
74+ browserWindowFactory func(context.Context, string) (*browserWindow, error)
75+ tabCreationWindow *browserWindow
76+ dispatchOnMainThread func(func())
77+
6878 // State
6979 tabs *entity.TabList
7080 tabsUC *usecase.ManageTabsUseCase
@@ -79,6 +89,7 @@ type App struct {
7989 // Pane management (used by coordinators)
8090 panesUC *usecase.ManagePanesUseCase
8191 workspaceViews map[entity.TabID]*component.WorkspaceView
92+ windowForTab map[entity.TabID]*browserWindow
8293 widgetFactory layout.WidgetFactory
8394 stackedPaneMgr *component.StackedPaneManager
8495
@@ -86,9 +97,8 @@ type App struct {
8697 keyboardHandler *input.KeyboardHandler
8798 globalShortcutHandler *input.GlobalShortcutHandler
8899
89- // Focus and border management
90- focusMgr *focus.Manager
91- borderMgr *focus.BorderManager
100+ // Focus tracking stays app-global.
101+ focusMgr *focus.Manager
92102
93103 resizeModeBorderTarget layout.Widget
94104
@@ -106,18 +116,6 @@ type App struct {
106116 // Web content (managed by content.Coordinator)
107117 faviconAdapter *adapter.FaviconAdapter
108118
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
121119 snapshotService port.SnapshotService
122120
123121 // Update management
@@ -126,13 +124,13 @@ type App struct {
126124 // ID generator for tabs/panes
127125 idCounter uint64
128126 idMu sync.Mutex
127+ windowIDCounter uint64
128+ windowIDMu sync.Mutex
129129 firstWebViewShownOnce sync.Once
130130
131131 movePaneToTabUC *usecase.MovePaneToTabUseCase
132132
133133 // Accent picker for dead keys support
134- accentPicker *component.AccentPicker
135- insertAccentUC *usecase.InsertAccentUseCase
136134 accentFocusProvider port.FocusedInputProvider
137135
138136 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
@@ -140,7 +138,9 @@ type App struct {
140138 deferredInitFn func()
141139
142140 // lifecycle
143- cancel context.CancelCauseFunc
141+ cancel context.CancelCauseFunc
142+ browserLaunchRelayOnce sync.Once
143+ browserLaunchRelayCloser io.Closer
144144 }
145145
146146 type floatingWorkspaceSession struct {
@@ -172,34 +172,51 @@ func New(deps *Dependencies) (*App, error) {
172172 ctx, cancel := context.WithCancelCause(deps.Ctx)
173173
174174 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 {
190191 if deps.Config == nil {
191192 return false
192193 }
193194 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,
195214 OnClipboardCopied: func(textLen int) {
196215 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+ )
203220 return false
204221 })
205222 glib.IdleAdd(&cb, 0)
@@ -244,6 +261,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
244261 }
245262 defer a.gtkApp.Unref()
246263 logging.Trace().Mark("gtk_app_created")
264+ a.dispatchOnMainThread = a.runOnMainThread
247265
248266 // Connect activate signal
249267 activateCb := func(_ gio.Application) {
@@ -272,18 +290,16 @@ func (a *App) onActivate(ctx context.Context) {
272290 // Configure pool background color early (prevents white flash), but avoid
273291 // synchronous prewarming that delays the first navigation on cold start.
274292 a.setupPoolBackgroundColor(ctx)
293+ a.initLayoutInfrastructure()
275294
276- if err := a.createMainWindow(ctx); err != nil {
295+ if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
277296 log.Error().Err(err).Msg("failed to create main window")
278297 return
279298 }
280299 logging.Trace().Mark("window_created")
281300
282- a.initLayoutInfrastructure()
283- a.initAppToasterOverlay()
284301 a.installCrashReportNotifier(ctx)
285- a.initFocusAndBorderOverlay()
286- a.initAccentPicker(ctx)
302+ a.initFocusManager()
287303 a.registerAccentHandlers(ctx)
288304 a.initDownloadHandler(ctx)
289305
@@ -293,8 +309,7 @@ func (a *App) onActivate(ctx context.Context) {
293309 a.initKeyboardHandler(ctx)
294310 a.initOmniboxConfig(ctx)
295311 a.initFindBarConfig(ctx)
296- a.initSessionManager(ctx)
297- a.initTabPicker(ctx)
312+ a.wireSessionManagerShortcut()
298313 a.initSnapshotService(ctx)
299314 a.initUpdateCoordinator(ctx)
300315 a.createInitialTab(ctx)
@@ -380,19 +395,38 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
380395 pool.PrewarmAsync(ctx, 0)
381396 }
382397
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+
384409 log := logging.FromContext(ctx)
385410 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
386411 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
388422 }
389- a.mainWindow = mainWindow
390423
391424 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)
393427 return false
394428 }
395- a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
429+ mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
396430
397431 // Create permission popup and dialog presenter
398432 if a.deps != nil && a.deps.PermissionUC != nil {
@@ -404,10 +438,9 @@ func (a *App) createMainWindow(ctx context.Context) error {
404438 if permPopup != nil {
405439 // Add popup to the main window's content overlay
406440 if w := permPopup.Widget(); w != nil {
407- a.mainWindow.AddOverlay(w)
441+ mainWindow.AddOverlay(w)
408442 }
409- permDialog := dialog.NewPermissionDialog(permPopup)
410- a.deps.PermissionUC.SetDialogPresenter(permDialog)
443+ browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup)
411444 }
412445 }
413446
@@ -415,155 +448,417 @@ func (a *App) createMainWindow(ctx context.Context) error {
415448 indicator := component.NewWebRTCPermissionIndicator()
416449 if indicator != nil {
417450 if w := indicator.Widget(); w != nil {
418- a.mainWindow.AddOverlay(w)
419- a.webrtcIndicator = indicator
451+ mainWindow.AddOverlay(w)
420452 }
453+ browserWindow.webrtcIndicator = indicator
454+ }
455+ a.wireBrowserWindowPermissionIndicator(browserWindow)
456+ if a.kbDispatcher != nil {
457+ a.initBrowserWindowInput(ctx, browserWindow)
421458 }
459+ if a.tabCoord != nil {
460+ a.wireBrowserWindowTabBar(ctx, browserWindow)
461+ }
462+ browserWindow.initChrome(ctx, a)
422463
423464 // Apply GTK CSS styling from theme manager.
424465 if a.deps == nil || a.deps.Theme == nil {
425- return nil
466+ return browserWindow, nil
426467 }
427- if display := a.mainWindow.Window().GetDisplay(); display != nil {
468+ if display := mainWindow.Window().GetDisplay(); display != nil {
428469 a.deps.Theme.ApplyToDisplay(ctx, display)
429470 }
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+ }
430489 return nil
431490 }
432491
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+}
436503
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
439517 }
440518
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 {
443534 return
444535 }
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+}
445561
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 {
450569 return
451570 }
452- gtkWidget := toasterWidget.GtkWidget()
453- if gtkWidget == nil {
571+ if entering {
572+ bw.mainWindow.TabBar().SetVisible(false)
454573 return
455574 }
456- a.mainWindow.AddOverlay(gtkWidget)
575+ a.updateBrowserWindowTabBarVisibility(bw)
576+}
457577
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 {
462580 return
463581 }
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)
466584 return
467585 }
468- a.mainWindow.AddOverlay(modeGtkWidget)
586+ shouldShow := bw.mainWindow.TabBar().Count() > 1
587+ bw.mainWindow.TabBar().SetVisible(shouldShow)
469588 }
470589
471-func (a *App) installCrashReportNotifier(ctx context.Context) {
472- if a.deps == nil {
590+func (a *App) activateBrowserWindow(bw *browserWindow) {
591+ if bw == nil {
473592 return
474593 }
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)
478605 }
606+ a.updateWindowTitleFromActivePane(bw.activeTabID)
479607 }
480608
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 {
490611 return
491612 }
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 {
494618 return
495619 }
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 {
498627 return
499628 }
500- a.mainWindow.AddOverlay(gtkWidget)
629+ a.ensureWindowForTabMap()
630+ a.windowForTab[tabID] = bw
631+ if bw.activeTabID == "" {
632+ bw.activeTabID = tabID
633+ }
501634 }
502635
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+}
505645
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 {
508648 return
509649 }
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+}
510659
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 {
515662 return
516663 }
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+}
517695
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)
524718 }
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()
525739 }
740+ return nil
741+}
526742
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+}
529793
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+}
542808
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+ })
544835 }
545836
546837 // 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.
548839 func (a *App) registerAccentHandlers(ctx context.Context) {
549840 log := logging.FromContext(ctx)
550841
551- if a.insertAccentUC == nil {
552- log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
553- return
554- }
555842 if a.engine == nil {
556843 log.Debug().Msg("engine not available, skipping accent handler registration")
557844 return
558845 }
559846
560- if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {
847+ if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil {
561848 log.Error().Err(err).Msg("failed to register accent handlers")
562849 } else {
563850 log.Info().Msg("registered accent key handlers")
564851 }
565852 }
566853
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+
567862 func (a *App) initDownloadHandler(ctx context.Context) {
568863 log := logging.FromContext(ctx)
569864
@@ -596,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
596891 }
597892
598893 // Create download event adapter to show toasts.
599- eventAdapter := &downloadEventAdapter{app: a}
894+ eventAdapter := newDownloadEventAdapter(a)
600895
601896 // Create use case for preparing download destinations with file deduplication.
602897 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
@@ -611,29 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) {
611906
612907 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
613908 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+ }
615931 }
616932
617933 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
934+ spec := d.toastSpecForEvent(event)
935+ if !spec.show {
936+ return
937+ }
938+
618939 // Must schedule on GTK main thread.
619940 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+ )
632947 return false
633948 })
634949 glib.IdleAdd(&cb, 0)
635950 }
636951
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+
6371044 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
6381045 type autoCopyConfigFn struct {
6391046 fn func() bool
@@ -647,57 +1054,51 @@ func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool {
6471054 }
6481055
6491056 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) {
6501065 log := logging.FromContext(ctx)
6511066
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 {
6531071 return
6541072 }
6551073
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)
6591077 if action == input.ActionClosePane {
660- if a.closeAndReleaseActiveFloatingPane(ctx) {
1078+ if a.closeAndReleaseActiveFloatingPane(actionCtx) {
6611079 return nil
6621080 }
6631081 }
664- return a.kbDispatcher.Dispatch(ctx, action)
1082+ return a.kbDispatcher.Dispatch(actionCtx, action)
6651083 })
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)
6681087 })
669- a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
1088+ bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
1089+ a.activateBrowserWindow(bw)
6701090 a.handleModeChange(ctx, from, to)
6711091 })
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() {
6821094 return input.RoutePassToWidget
6831095 }
684- if a.tabPicker != nil && a.tabPicker.IsVisible() {
1096+ if bw.tabPicker != nil && bw.tabPicker.IsVisible() {
6851097 return input.RoutePassToWidget
6861098 }
6871099
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.
6961100 if a.accentFocusProvider != nil {
6971101 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)
7011102 if kc.Modifiers&input.ModAlt != 0 {
7021103 return input.RouteHandleShortcuts
7031104 }
@@ -705,8 +1106,6 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
7051106 }
7061107 }
7071108
708- // WebView focused: text/dead keys pass through for native IM compose,
709- // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system
7101109 if input.IsShortcutModified(kc.Modifiers) {
7111110 return input.RouteHandleShortcuts
7121111 }
@@ -714,37 +1113,48 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
7141113 return input.RoutePassToWidget
7151114 }
7161115
717- // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system
7181116 return input.RouteHandleShortcuts
7191117 })
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())
7251120
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(
7301122 ctx,
731- a.mainWindow.Window(),
1123+ bw.mainWindow.Window(),
7321124 &a.deps.Config.Workspace,
7331125 &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)
7371130 },
7381131 )
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
7411142 }
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+ })
7421151 }
7431152
7441153 // handleAccentKeyPress handles accent key press events for GTK entry widgets
7451154 // (omnibox and find bar). Returns true if the key was consumed.
7461155 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 {
7481158 return false
7491159 }
7501160 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
@@ -752,7 +1162,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
7521162 }
7531163 shiftHeld := state&gdk.ShiftMaskValue != 0
7541164 if char := input.KeyvalToRune(keyval); char != 0 {
755- return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld)
1165+ return handler.OnKeyPressed(ctx, char, shiftHeld)
7561166 }
7571167 return false
7581168 }
@@ -760,14 +1170,47 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
7601170 // handleAccentKeyRelease handles accent key release events for GTK entry widgets
7611171 // (omnibox and find bar).
7621172 func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
763- if a.insertAccentUC == nil {
1173+ handler := a.activeAccentHandler()
1174+ if handler == nil {
7641175 return
7651176 }
7661177 if char := input.KeyvalToRune(keyval); char != 0 {
767- a.insertAccentUC.OnKeyReleased(ctx, char)
1178+ handler.OnKeyReleased(ctx, char)
7681179 }
7691180 }
7701181
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+
7711214 type omniboxCallbacks struct {
7721215 OnNavigate func(url string)
7731216 OnToast func(ctx context.Context, message string, level component.ToastLevel)
@@ -802,6 +1245,7 @@ func buildOmniboxConfig(
8021245 ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
8031246 DefaultSearch: deps.Config.DefaultSearchEngine,
8041247 InitialBehavior: deps.Config.Omnibox.InitialBehavior,
1248+ MostVisitedDays: deps.Config.Omnibox.MostVisitedDays,
8051249 SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
8061250 UIScale: deps.Config.DefaultUIScale,
8071251 OnNavigate: callbacks.OnNavigate,
@@ -930,89 +1374,18 @@ func (a *App) initFindBarConfig(ctx context.Context) {
9301374 }
9311375 }
9321376
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-
10061377 // ToggleSessionManager shows or hides the session manager.
10071378 func (a *App) ToggleSessionManager(ctx context.Context) {
1008- if a.sessionManager == nil {
1379+ bw := a.lastFocusedBrowserWindow()
1380+ if bw == nil || bw.sessionManager == nil {
10091381 return
10101382 }
1011- a.sessionManager.Toggle(ctx)
1383+ bw.sessionManager.Toggle(ctx)
10121384 }
10131385
10141386 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 {
10161389 return
10171390 }
10181391 wsView := a.activeWorkspaceView()
@@ -1033,24 +1406,24 @@ func (a *App) attachTabPickerToActivePane() {
10331406 return
10341407 }
10351408
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 {
10391412 return
10401413 }
10411414 }
10421415
10431416 // If currently attached to a different pane overlay, detach.
1044- if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
1417+ if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID {
10451418 for _, view := range a.workspaceViews {
10461419 if view == nil {
10471420 continue
10481421 }
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)
10521425 } else if parent != nil {
1053- a.tabPickerWidget.Unparent()
1426+ bw.tabPickerWidget.Unparent()
10541427 }
10551428 break
10561429 }
@@ -1058,20 +1431,21 @@ func (a *App) attachTabPickerToActivePane() {
10581431 }
10591432
10601433 // 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()
10631436 }
10641437
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
10681441 }
10691442
10701443 func (a *App) HandleMovePaneToTab(ctx context.Context) error {
10711444 if a.movePaneToTabUC == nil {
10721445 return nil
10731446 }
1074- if a.tabPicker == nil {
1447+ bw := a.lastFocusedBrowserWindow()
1448+ if bw == nil || bw.tabPicker == nil {
10751449 return nil
10761450 }
10771451
@@ -1103,7 +1477,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
11031477 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
11041478
11051479 a.attachTabPickerToActivePane()
1106- a.tabPicker.Show(ctx, items)
1480+ bw.tabPicker.Show(ctx, items)
11071481 return nil
11081482 }
11091483
@@ -1134,6 +1508,17 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
11341508 return nil
11351509 }
11361510
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+
11371522 out, err := a.movePaneToTabUC.Execute(*in)
11381523 if err != nil {
11391524 return err
@@ -1141,14 +1526,24 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
11411526 if out == nil || out.TargetTab == nil {
11421527 return nil
11431528 }
1529+ if out.NewTabCreated && targetWindow != nil {
1530+ a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
1531+ }
11441532
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)
11471535 a.updateTabBarVisibility(ctx)
11481536 a.MarkDirty()
11491537 return nil
11501538 }
11511539
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+
11521547 func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
11531548 if a.movePaneToTabUC == nil {
11541549 return nil, nil
@@ -1171,7 +1566,13 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
11711566 return in, activeTab
11721567 }
11731568
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+) {
11751576 if out == nil || out.TargetTab == nil {
11761577 return
11771578 }
@@ -1181,10 +1582,10 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
11811582 }
11821583
11831584 if out.SourceTabClosed {
1184- a.removeSourceTabUI(sourceTabID)
1585+ a.removeSourceTabUI(sourceTabID, sourceWindow)
11851586 }
11861587 if out.NewTabCreated {
1187- a.ensureTargetTabUI(ctx, out.TargetTab)
1588+ a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow)
11881589 }
11891590
11901591 if !out.SourceTabClosed {
@@ -1193,20 +1594,20 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
11931594 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
11941595 }
11951596
1196-func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1597+func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserWindow) {
11971598 a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
11981599 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)
12011602 }
12021603 }
12031604
1204-func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
1605+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
12051606 if tab == nil {
12061607 return
12071608 }
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)
12101611 }
12111612 a.createWorkspaceViewWithoutAttach(ctx, tab)
12121613 }
@@ -1254,12 +1655,19 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor
12541655 }
12551656 }
12561657
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+) {
12581663 if out == nil || out.TargetTab == nil {
12591664 return
12601665 }
12611666
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+ }
12631671 if out.SourceTabClosed {
12641672 switchToTarget = true
12651673 }
@@ -1268,8 +1676,14 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo
12681676 }
12691677
12701678 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)
12731687 }
12741688 a.switchWorkspaceView(ctx, out.TargetTab.ID)
12751689 }
@@ -1280,42 +1694,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) {
12801694 }
12811695 }
12821696
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-
13191697 func (a *App) initSnapshotService(ctx context.Context) {
13201698 log := logging.FromContext(ctx)
13211699
@@ -1355,7 +1733,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
13551733 a.updateCoord = coordinator.NewUpdateCoordinator(
13561734 a.deps.CheckUpdateUC,
13571735 a.deps.ApplyUpdateUC,
1358- a.appToaster,
1736+ func(ctx context.Context, msg string, level component.ToastLevel) {
1737+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, level)
1738+ },
13591739 a.deps.Config.Update.EnableOnStartup,
13601740 a.deps.Config.Update.AutoDownload,
13611741 )
@@ -1394,29 +1774,59 @@ func (a *App) createInitialTab(ctx context.Context) {
13941774 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
13951775 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
13961776 // 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)
14001778 } else {
14011779 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
14021780 // 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)
14061782 return
14071783 }
14081784 }
14091785
14101786 // 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 {
14161788 log.Error().Err(err).Msg("failed to create initial tab")
14171789 }
14181790 }
14191791
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+
14201830 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
14211831 log := logging.FromContext(ctx)
14221832
@@ -1442,6 +1852,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
14421852 return fmt.Errorf("failed to build tab list from snapshot")
14431853 }
14441854
1855+ a.clearSessionRestoreUIState(ctx)
1856+
14451857 // Replace tabs in-place to preserve references held by TabCoordinator
14461858 a.tabs.ReplaceFrom(restoredTabs)
14471859
@@ -1450,6 +1862,10 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
14501862 tabBar := a.mainWindow.TabBar()
14511863 for _, tab := range a.tabs.Tabs {
14521864 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+ }
14531869 if tabBar != nil {
14541870 tabBar.AddTab(tab)
14551871 }
@@ -1479,6 +1895,7 @@ func (a *App) finalizeActivation(ctx context.Context) {
14791895 if a.mainWindow != nil {
14801896 a.mainWindow.Show()
14811897 }
1898+ a.startBrowserLaunchRelayListener(ctx)
14821899 log.Info().Msg("main window displayed")
14831900
14841901 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
@@ -1504,13 +1921,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
15041921 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
15051922
15061923 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+ )
15141929 return false
15151930 })
15161931 glib.IdleAdd(&cb, 0)
@@ -1562,6 +1977,9 @@ func (a *App) onShutdown(ctx context.Context) {
15621977 }
15631978 }
15641979
1980+ // Stop accepting relaunches before teardown starts.
1981+ a.closeBrowserLaunchRelayListener()
1982+
15651983 // Cancel context to signal all goroutines
15661984 a.cancel(errors.New("application shutdown"))
15671985
@@ -1665,6 +2083,10 @@ func (a *App) initCoordinators(ctx context.Context) {
16652083 a.createWorkspaceView(ctx, tab)
16662084 })
16672085 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+ }
16682090 a.switchWorkspaceView(ctx, tab.ID)
16692091 })
16702092 a.tabCoord.SetOnQuit(a.Quit)
@@ -1674,27 +2096,13 @@ func (a *App) initCoordinators(ctx context.Context) {
16742096 a.attachPopupToTab(ctx, tabID, pane, wv)
16752097 })
16762098
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)
16842101 }
16852102
16862103 // 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)
16982106 })
16992107
17002108 // 3. Workspace Coordinator
@@ -1767,8 +2175,8 @@ func (a *App) initCoordinators(ctx context.Context) {
17672175 })
17682176
17692177 // 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)
17722180 })
17732181
17742182 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -1813,8 +2221,12 @@ func (a *App) initCoordinators(ctx context.Context) {
18132221 a.deps.ZoomUC,
18142222 a.deps.CopyURLUC,
18152223 a.deps.Config.Workspace.NewPaneURL,
2224+ a.contentCoord.ActivePaneID,
18162225 )
18172226 a.wireKeyboardActions()
2227+ for _, bw := range a.browserWindows {
2228+ a.initBrowserWindowInput(ctx, bw)
2229+ }
18182230
18192231 log.Debug().Msg("coordinators initialized")
18202232 }
@@ -1837,10 +2249,16 @@ func (a *App) wireKeyboardActions() {
18372249 a.CloseFindBar(ctx)
18382250 return nil
18392251 })
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+ }
18412256 return a.HandleMovePaneToTab(ctx)
18422257 })
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+ }
18442262 return a.HandleMovePaneToNextTab(ctx)
18452263 })
18462264 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
@@ -1857,33 +2275,73 @@ func (a *App) wireKeyboardActions() {
18572275 }
18582276
18592277 func (a *App) wireWebRTCPermissionIndicator() {
1860- if a.webrtcIndicator == nil || a.contentCoord == nil {
2278+ if a.contentCoord == nil {
18612279 return
18622280 }
18632281 ctx := context.Background()
18642282 if a.deps != nil && a.deps.Ctx != nil {
18652283 ctx = a.deps.Ctx
18662284 }
1867- log := logging.FromContext(ctx)
18682285
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)
18712300
18722301 switch state {
18732302 case content.PermissionActivityRequesting:
1874- a.webrtcIndicator.MarkRequesting(permTypes)
2303+ bw.webrtcIndicator.MarkRequesting(permTypes)
18752304 case content.PermissionActivityAllowed:
1876- a.webrtcIndicator.MarkAllowed(permTypes)
2305+ bw.webrtcIndicator.MarkAllowed(permTypes)
18772306 case content.PermissionActivityBlocked:
1878- a.webrtcIndicator.MarkBlocked(permTypes)
2307+ bw.webrtcIndicator.MarkBlocked(permTypes)
18792308 }
18802309
18812310 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()
18832330 }
18842331 })
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)
18852343
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) {
18872345 if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
18882346 return
18892347 }
@@ -1908,27 +2366,17 @@ func (a *App) wireWebRTCPermissionIndicator() {
19082366 }
19092367 }
19102368
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)
19272370 })
19282371 }
19292372
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 == "" {
19322380 return
19332381 }
19342382
@@ -1943,11 +2391,40 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string,
19432391 }
19442392
19452393 if record == nil {
1946- a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
2394+ indicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
19472395 return
19482396 }
19492397
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
19512428 }
19522429
19532430 // generateID generates a unique ID for tabs and panes.
@@ -1989,27 +2466,39 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
19892466
19902467 // updateWindowTitle updates the window title with the given page title.
19912468 // 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 {
19942474 return
19952475 }
19962476
1997- title := "Dumber"
2477+ title := appTitle
19982478 if pageTitle != "" {
1999- title = pageTitle + " - Dumber"
2479+ title = pageTitle + " - " + appTitle
20002480 }
2001- a.mainWindow.SetTitle(title)
2481+ target.mainWindow.SetTitle(title)
20022482 }
20032483
20042484 // 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+ }
20072496 if ws == nil || a.contentCoord == nil {
2008- a.updateWindowTitle("")
2497+ a.updateWindowTitle("", a.browserWindowForTab(tabID))
20092498 return
20102499 }
20112500 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2012- a.updateWindowTitle(title)
2501+ a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
20132502 }
20142503
20152504 // handleModeChange is called when the input mode changes.
@@ -2026,8 +2515,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
20262515
20272516 // Update global border overlay visibility based on mode.
20282517 // 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)
20312520 }
20322521
20332522 // Show/hide mode indicator toaster based on config.
@@ -2036,25 +2525,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
20362525
20372526 // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
20382527 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 {
20402530 return
20412531 }
20422532
20432533 // Check if mode indicator toaster is enabled in config.
20442534 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
2045- a.modeToaster.Hide()
2535+ bw.modeToaster.Hide()
20462536 return
20472537 }
20482538
20492539 if mode == input.ModeNormal {
2050- a.modeToaster.Hide()
2540+ bw.modeToaster.Hide()
20512541 return
20522542 }
20532543
20542544 // Show persistent toaster at bottom-left with mode display name.
20552545 // Mode class is applied atomically with Show() to avoid visual flicker.
20562546 modeClass := getModeToastClass(mode)
2057- a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
2547+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
20582548 component.WithDuration(0), // Persistent until mode exits.
20592549 component.WithPosition(component.ToastPositionBottomLeft),
20602550 component.WithModeClass(modeClass),
@@ -2122,14 +2612,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
21222612 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
21232613 a.createWorkspaceViewWithoutAttach(ctx, tab)
21242614
2615+ target := a.browserWindowForTab(tab.ID)
2616+ if target != nil {
2617+ a.setBrowserWindowForTab(tab.ID, target)
2618+ }
2619+
21252620 // Attach to content area
21262621 wsView := a.workspaceViews[tab.ID]
2127- if wsView != nil && a.mainWindow != nil {
2622+ if wsView != nil && target != nil && target.mainWindow != nil {
21282623 widget := wsView.Widget()
21292624 if widget != nil {
21302625 gtkWidget := widget.GtkWidget()
21312626 if gtkWidget != nil {
2132- a.mainWindow.SetContent(gtkWidget)
2627+ target.mainWindow.SetContent(gtkWidget)
21332628 }
21342629 }
21352630 }
@@ -2202,6 +2697,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
22022697
22032698 // activeWorkspace returns the workspace of the active tab.
22042699 func (a *App) activeWorkspace() *entity.Workspace {
2700+ if a.tabs == nil {
2701+ return nil
2702+ }
22052703 activeTab := a.tabs.ActiveTab()
22062704 if activeTab == nil {
22072705 return nil
@@ -2308,12 +2806,15 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
23082806 }
23092807
23102808 // 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 {
23122813 a.mainWindow.SetContent(gtkWidget)
23132814 }
23142815
23152816 // Update window title with the new active pane's title
2316- a.updateWindowTitleFromActivePane()
2817+ a.updateWindowTitleFromActivePane(tabID)
23172818 a.syncFloatingFocus()
23182819
23192820 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
@@ -2494,8 +2995,8 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve
24942995 return
24952996 }
24962997
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)
24993000 if widget != nil {
25003001 widgetPtr = widget.GoPointer()
25013002 }
@@ -2755,9 +3256,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
27553256 if session.omnibox == nil {
27563257 cfg := a.omniboxCfg
27573258 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)
27613260 }
27623261
27633262 omnibox := component.NewOmnibox(ctx, cfg)
@@ -3183,13 +3682,16 @@ func (a *App) initConfigWatcher(ctx context.Context) {
31833682 a.deps.OnConfigChange(func() {
31843683 cb := glib.SourceFunc(func(_ uintptr) bool {
31853684 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+ }
31933695 }
31943696 return false
31953697 })
@@ -3278,21 +3780,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
32783780
32793781 switch status.State {
32803782 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)
32843784 case port.FilterStateActive:
32853785 // Apply filters to existing webviews that were created before filters loaded
32863786 if a.contentCoord != nil && a.deps.FilterManager != nil {
32873787 a.contentCoord.ApplyFiltersToAll(ctx)
32883788 log.Debug().Msg("applied filters to all existing webviews")
32893789 }
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)
32933791 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)
32973793 }
32983794 }
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 collapsed to keep the page light
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 {
115115 shortcutsUC *usecase.SearchShortcutsUseCase
116116 defaultSearch string
117117 initialBehavior entity.OmniboxInitialBehavior
118+ mostVisitedDays int
118119 saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
119120 ctx context.Context
120121
@@ -161,6 +162,7 @@ type OmniboxConfig struct {
161162 ShortcutsUC *usecase.SearchShortcutsUseCase
162163 DefaultSearch string
163164 InitialBehavior entity.OmniboxInitialBehavior
165+ MostVisitedDays int
164166 SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
165167 UIScale float64 // UI scale for favicon sizing
166168 OnNavigate func(url string) // Callback when user navigates via omnibox
@@ -194,6 +196,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
194196 shortcutsUC: cfg.ShortcutsUC,
195197 defaultSearch: cfg.DefaultSearch,
196198 initialBehavior: cfg.InitialBehavior,
199+ mostVisitedDays: cfg.MostVisitedDays,
197200 saveInitialBehaviorFn: cfg.SaveInitialBehavior,
198201 onToast: cfg.OnToast,
199202 onAccentKeyPress: cfg.OnAccentKeyPress,
@@ -725,8 +728,9 @@ func (o *Omnibox) initList() error {
725728 o.listBox.AddCssClass("omnibox-listbox")
726729 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
727730
728- rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
731+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
729732 o.restoreEntryToRealInput()
733+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
730734 if row == nil {
731735 o.mu.Lock()
732736 o.selectedIndex = -1
@@ -742,7 +746,8 @@ func (o *Omnibox) initList() error {
742746 o.listBox.ConnectRowSelected(&rowSelectedCb)
743747
744748 // 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)
746751 if row == nil {
747752 return
748753 }
@@ -750,12 +755,17 @@ func (o *Omnibox) initList() error {
750755 o.mu.RLock()
751756 mode := o.viewMode
752757 bangMode := o.bangMode
758+ bangSuggestions := o.bangSuggestions
753759 suggestions := o.suggestions
754760 favorites := o.favorites
755761 o.mu.RUnlock()
756762
757763 // Don't navigate in bang mode - activating a bang should fill it in
758764 if bangMode {
765+ if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil {
766+ o.entry.SetText(bangText)
767+ o.entry.SetPosition(-1)
768+ }
759769 return
760770 }
761771
@@ -1453,6 +1463,13 @@ func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggest
14531463 return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
14541464 }
14551465
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+
14561473 func visibleResultCount(total, maxVisible int) int {
14571474 if total <= 0 {
14581475 return 0
@@ -1590,6 +1607,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
15901607 // Capture effective result limit on the GTK main thread before spawning goroutine
15911608 initialLimit := o.effectiveMaxRows()
15921609 initialBehavior := o.initialBehavior
1610+ mostVisitedDays := o.mostVisitedDays
15931611
15941612 go func() {
15951613 ctx := o.ctx
@@ -1616,7 +1634,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
16161634 err error
16171635 )
16181636 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+ }
16201642 if err == nil && len(results) > initialLimit {
16211643 results = results[:initialLimit]
16221644 }
@@ -2283,8 +2305,8 @@ func (o *Omnibox) navigateToSelected() {
22832305 }
22842306
22852307 // 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)
22882310 o.entry.SetPosition(-1)
22892311 return
22902312 }
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) {
199199 }
200200 }
201201
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+
202213 func TestVisibleGhostSuggestion(t *testing.T) {
203214 suggestions := []Suggestion{
204215 {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
@@ -4,6 +4,7 @@ package component
44 import (
55 "context"
66 "sync"
7+ "time"
78
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/bnema/puregotk/v4/glib"
@@ -29,11 +30,14 @@ type ProgressBar struct {
2930 ctx context.Context
3031 progressBar layout.ProgressBarWidget
3132
32- visible bool
33- currentValue float64 // Current displayed value
34- targetValue float64 // Target value to animate towards
35- animationTimer uint // Timer source ID for animation
36- timeoutTimer uint // Timer source ID for auto-hide timeout
33+ visible bool
34+ currentValue float64 // Current displayed value
35+ targetValue float64 // Target value to animate towards
36+ animationTimer uint // Timer source ID for animation
37+ timeoutTimer uint // Timer source ID for auto-hide timeout
38+ lastShowAt time.Time
39+ lastProgressAt time.Time
40+ progressEventSeq uint64
3741
3842 mu sync.Mutex
3943 }
@@ -85,12 +89,17 @@ func (pb *ProgressBar) SetProgress(progress float64) {
8589 if ctx == nil {
8690 ctx = context.Background()
8791 }
92+ now := time.Now()
8893 logging.FromContext(ctx).
89- Debug().
94+ Trace().
9095 Float64("incoming_progress", incomingProgress).
9196 Float64("current_value", pb.currentValue).
9297 Float64("target_value", pb.targetValue).
9398 Bool("visible", pb.visible).
99+ Bool("animation_running", pb.animationTimer != 0).
100+ Bool("timeout_running", pb.timeoutTimer != 0).
101+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
102+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
94103 Msg("setting progress")
95104
96105 // Clamp progress to valid range
@@ -101,6 +110,11 @@ func (pb *ProgressBar) SetProgress(progress float64) {
101110 }
102111
103112 pb.targetValue = progress
113+ pb.lastProgressAt = now
114+ pb.progressEventSeq++
115+ if pb.visible {
116+ pb.resetTimeout()
117+ }
104118
105119 // For large jumps (>0.3) or completion, update immediately
106120 diff := progress - pb.currentValue
@@ -146,6 +160,16 @@ func (pb *ProgressBar) startAnimation() {
146160 })
147161
148162 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
163+ ctx := pb.ctx
164+ if ctx == nil {
165+ ctx = context.Background()
166+ }
167+ logging.FromContext(ctx).
168+ Trace().
169+ Uint64("progress_event_seq", pb.progressEventSeq).
170+ Float64("current_value", pb.currentValue).
171+ Float64("target_value", pb.targetValue).
172+ Msg("progress bar animation started")
149173 }
150174
151175 // initialProgressFraction is the fraction set when the progress bar first
@@ -172,6 +196,7 @@ func (pb *ProgressBar) Show() {
172196
173197 if !pb.visible {
174198 pb.visible = true
199+ pb.lastShowAt = time.Now()
175200 // Set an initial non-zero fraction so the bar is visually noticeable
176201 // immediately. Without this, the bar is technically visible but
177202 // renders as empty (0% fill) until the first progress callback,
@@ -182,10 +207,10 @@ func (pb *ProgressBar) Show() {
182207 pb.progressBar.SetVisible(true)
183208 }
184209
185- // Reset timeout timer on every Show call
210+ // Reset timeout timer on every Show call.
186211 pb.resetTimeout()
187212
188- logging.FromContext(pb.ctx).
213+ logging.FromContext(ctx).
189214 Debug().
190215 Bool("visible", pb.visible).
191216 Float64("current_value", pb.currentValue).
@@ -196,46 +221,96 @@ func (pb *ProgressBar) Show() {
196221 // resetTimeout cancels any existing timeout and starts a new one.
197222 // Must be called with lock held.
198223 func (pb *ProgressBar) resetTimeout() {
199- // Cancel existing timeout
224+ pb.resetTimeoutAfter(time.Duration(progressTimeoutMs) * time.Millisecond)
225+}
226+
227+// resetTimeoutAfter cancels any existing timeout and starts a new one after
228+// the requested delay. Must be called with lock held.
229+func (pb *ProgressBar) resetTimeoutAfter(delay time.Duration) {
230+ if delay <= 0 {
231+ delay = time.Millisecond
232+ }
233+
234+ // Cancel existing timeout.
200235 if pb.timeoutTimer != 0 {
201236 glib.SourceRemove(pb.timeoutTimer)
202237 pb.timeoutTimer = 0
203238 }
204239
205- // Start new timeout
240+ timeoutMs := int(delay / time.Millisecond)
241+ if delay%time.Millisecond != 0 {
242+ timeoutMs++
243+ }
244+ if timeoutMs < 1 {
245+ timeoutMs = 1
246+ }
247+
206248 cb := glib.SourceFunc(func(_ uintptr) bool {
207249 pb.mu.Lock()
208250 defer pb.mu.Unlock()
209251
210- // Clear timer ID (timer is being removed)
252+ now := time.Now()
253+ remaining := pb.timeoutRemainingLocked(now)
254+ ctx := pb.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ logging.FromContext(ctx).
259+ Debug().
260+ Float64("current_value", pb.currentValue).
261+ Float64("target_value", pb.targetValue).
262+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
263+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
264+ Int64("timeout_remaining_ms", remaining.Milliseconds()).
265+ Msg("progress bar timeout fired")
266+
267+ // Clear timer ID (timer is being removed).
211268 pb.timeoutTimer = 0
212269
213- // Hide the progress bar inline to avoid race condition
214- pb.hideInternal()
270+ if remaining > 0 {
271+ pb.resetTimeoutAfter(remaining)
272+ return false
273+ }
274+
275+ // Hide the progress bar inline to avoid race condition.
276+ pb.hideInternal("timeout")
215277 return false // Don't repeat
216278 })
217279
218- pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0)
280+ pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0)
219281 }
220282
221283 // Hide makes the progress bar invisible and resets state.
222284 func (pb *ProgressBar) Hide() {
285+ pb.HideWithReason("manual")
286+}
287+
288+// HideWithReason makes the progress bar invisible and records why it was hidden.
289+func (pb *ProgressBar) HideWithReason(reason string) {
223290 pb.mu.Lock()
224291 defer pb.mu.Unlock()
225292
226- pb.hideInternal()
293+ pb.hideInternal(reason)
227294 }
228295
229296 // hideInternal performs the actual hide operation.
230297 // Must be called with lock held.
231-func (pb *ProgressBar) hideInternal() {
298+func (pb *ProgressBar) hideInternal(reason string) {
232299 ctx := pb.ctx
233300 if ctx == nil {
234301 ctx = context.Background()
235302 }
303+ now := time.Now()
236304 logging.FromContext(ctx).
237305 Debug().
238306 Bool("visible", pb.visible).
307+ Str("reason", reason).
308+ Float64("current_value", pb.currentValue).
309+ Float64("target_value", pb.targetValue).
310+ Bool("animation_running", pb.animationTimer != 0).
311+ Bool("timeout_running", pb.timeoutTimer != 0).
312+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
313+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
239314 Msg("progress bar hide before")
240315
241316 if pb.visible {
@@ -257,10 +332,35 @@ func (pb *ProgressBar) hideInternal() {
257332 // Reset values
258333 pb.currentValue = 0
259334 pb.targetValue = 0
335+ pb.lastShowAt = time.Time{}
336+ pb.lastProgressAt = time.Time{}
260337 pb.progressBar.SetFraction(0)
261338 }
262339 }
263340
341+func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration {
342+ lastActivityAt := pb.lastShowAt
343+ if pb.lastProgressAt.After(lastActivityAt) {
344+ lastActivityAt = pb.lastProgressAt
345+ }
346+ if lastActivityAt.IsZero() {
347+ return 0
348+ }
349+
350+ remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt)
351+ if remaining < 0 {
352+ return 0
353+ }
354+ return remaining
355+}
356+
357+func sinceTimeMs(start, now time.Time) int64 {
358+ if start.IsZero() {
359+ return -1
360+ }
361+ return now.Sub(start).Milliseconds()
362+}
363+
264364 // IsVisible returns whether the progress bar is currently visible.
265365 func (pb *ProgressBar) IsVisible() bool {
266366 pb.mu.Lock()
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 {
104104 // LaunchBrowserURL opens a URL in a new dumber browser window.
105105 // Optional: if nil, standalone omnibox web navigations are logged only.
106106 LaunchBrowserURL func(uri string)
107+ // BrowserLaunchRelay listens for in-process browser launch requests.
108+ BrowserLaunchRelay port.BrowserLaunchRelay
107109 }
108110
109111 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +18 -15 collapsed to keep the page light
M internal/ui/dispatcher/keyboard_test.go +46 -3 collapsed to keep the page light
M internal/ui/layout/split.go +81 -0 collapsed to keep the page light
M internal/ui/layout/split_test.go +43 -0 collapsed to keep the page light
M internal/ui/standalone_runtime_test.go +1 -1 collapsed to keep the page light
M internal/ui/theme/css.go +63 -0 collapsed to keep the page light
M internal/ui/theme/css_test.go +47 -0 collapsed to keep the page light
M internal/ui/window/main_window.go +18 -0 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close