detached c0d5b53

Merge pull request #328 from bnema/backlog/architecture-cleanup

chore(arch): clean up architecture audit backlog
commit
c0d5b534185a91df3819963af739b75acdce5c80
author
bnema · · 2026-06-26 17:17 UTC
committer
GitHub · · 2026-06-26 17:17 UTC
merge of
d3cb888 + b200f6b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
86 files · +3622 -1061
M .github/workflows/ci.yml +2 -2
@@ -55,7 +55,7 @@ jobs:
55 - name: Install dependencies55 - name: Install dependencies
56 run: |56 run: |
57 pacman -Syu --noconfirm57 pacman -Syu --noconfirm
58- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli58+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli
59 - uses: actions/checkout@v759 - uses: actions/checkout@v7
60 - name: Allow git commands in container checkout60 - name: Allow git commands in container checkout
61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -99,7 +99,7 @@ jobs:
99 - name: Install dependencies99 - name: Install dependencies
100 run: |100 run: |
101 pacman -Syu --noconfirm101 pacman -Syu --noconfirm
102- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus102+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus
103 - uses: actions/checkout@v7103 - uses: actions/checkout@v7
104 - name: Set up pinned Go toolchain104 - name: Set up pinned Go toolchain
105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
M .mockery.yaml +1 -0
@@ -107,6 +107,7 @@ packages:
107 FaviconRepository: {}107 FaviconRepository: {}
108 FaviconService: {}108 FaviconService: {}
109 FileSystem: {}109 FileSystem: {}
110+ LocalPathResolver: {}
110 FilterManager: {}111 FilterManager: {}
111 Focusable: {}112 Focusable: {}
112 FindController: {}113 FindController: {}
M README.md +5 -3
@@ -109,7 +109,7 @@ The floating pane is a temporary browser pane that can be toggled without changi
109 - `Alt+F` toggles the floating pane and preserves its state while hidden.109 - `Alt+F` toggles the floating pane and preserves its state while hidden.
110 - `Ctrl+W` closes the active pane; if the floating pane is active, it is fully released for a fresh next open.110 - `Ctrl+W` closes the active pane; if the floating pane is active, it is fully released for a fresh next open.
111 - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.111 - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.
112-- Some `Alt+<key>` bindings may conflict with WebKit shortcuts or desktop-level handlers.112+- Some `Alt+<key>` bindings may conflict with browser-engine shortcuts or desktop-level handlers.
113 113
114 See the [floating pane reference](https://dumber.bnema.dev/docs/reference/floating-pane) for setup and behavior details.114 See the [floating pane reference](https://dumber.bnema.dev/docs/reference/floating-pane) for setup and behavior details.
115 115
@@ -200,7 +200,7 @@ Bug reports and reproducible Wayland/backend issues are welcome.
200 200
201 ## Development201 ## Development
202 202
203-Dumber uses pure-Go bindings and requires GTK4/WebKitGTK runtime libraries for the GUI.203+Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.
204 204
205 Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.205 Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
206 206
@@ -209,7 +209,9 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
209 **Prerequisites:**209 **Prerequisites:**
210 210
211 - Go 1.26+211 - Go 1.26+
212-- WebKitGTK 6.0 and GTK4 development packages212+- GTK4 development packages
213+- CEF runtime (default backend)
214+- WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
213 - Brotli for compressed systemviews assets215 - Brotli for compressed systemviews assets
214 216
215 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.217 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
M cmd/dumber/main.go +6 -3
@@ -790,6 +790,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
790 updateChecker := updater.NewGitHubChecker()790 updateChecker := updater.NewGitHubChecker()
791 updateDownloader := updater.NewGitHubDownloader()791 updateDownloader := updater.NewGitHubDownloader()
792 updateApplier := updater.NewApplier(stateDir)792 updateApplier := updater.NewApplier(stateDir)
793+ localPaths := filesystem.New()
793 794
794 // Permission use case will be initialized later with dialog presenter795 // Permission use case will be initialized later with dialog presenter
795 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)796 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
@@ -800,8 +801,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
800 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)801 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)
801 802
802 return &useCases{803 return &useCases{
803- tabs: usecase.NewManageTabsUseCase(idGenerator),804+ tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths),
804- panes: usecase.NewManagePanesUseCase(idGenerator),805+ panes: usecase.NewManagePanesUseCase(idGenerator, localPaths),
805 history: historyUC,806 history: historyUC,
806 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),807 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
807 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),808 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
@@ -882,6 +883,7 @@ func buildUIDependencies(
882 focusProvider := textinput.NewFocusProvider()883 focusProvider := textinput.NewFocusProvider()
883 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)884 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
884 runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager())885 runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager())
886+ localPaths := filesystem.New()
885 887
886 uiDeps := &ui.Dependencies{888 uiDeps := &ui.Dependencies{
887 Ctx: ctx,889 Ctx: ctx,
@@ -935,7 +937,8 @@ func buildUIDependencies(
935 CheckUpdateUC: uc.checkUpdate,937 CheckUpdateUC: uc.checkUpdate,
936 ApplyUpdateUC: uc.applyUpdate,938 ApplyUpdateUC: uc.applyUpdate,
937 SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),939 SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
938- FileSystem: filesystem.New(),940+ FileSystem: localPaths,
941+ LocalPathResolver: localPaths,
939 AccentFocusProvider: focusProvider,942 AccentFocusProvider: focusProvider,
940 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {943 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
941 return textinput.NewGTKEntryTarget(entry)944 return textinput.NewGTKEntryTarget(entry)
M docs/cli/index.md +1 -1
@@ -141,7 +141,7 @@ dumber doctor [flags]
141 141
142 | Flag | Description |142 | Flag | Description |
143 |------|-------------|143 |------|-------------|
144-| `--runtime` | Only run runtime checks (GTK4/WebKitGTK) |144+| `--runtime` | Only run runtime checks for GTK and WebKit fallback libraries |
145 | `--media` | Only run media checks (GStreamer/VA-API) |145 | `--media` | Only run media checks (GStreamer/VA-API) |
146 146
147 ### setup147 ### setup
M docs/config/index.md +122 -101
@@ -245,41 +245,82 @@ When Noctalia rewrites the watched file, Dumber reapplies the same resolved them
245 | Key | Type | Default | Description |245 | Key | Type | Default | Description |
246 |-----|------|---------|-------------|246 |-----|------|---------|-------------|
247 | `debug.enable_devtools` | bool | `true` | Enable browser developer tools (F12, Inspect Element) |247 | `debug.enable_devtools` | bool | `true` | Enable browser developer tools (F12, Inspect Element) |
248+| `engine.cef.log_file` | string | `""` | CEF runtime log path |
249+| `engine.cef.log_severity` | int32 | `0` | CEF log severity (`0`, `1`, `2`, `3`, `4`, `99`) |
250+| `engine.cef.trace_handlers` | bool | `false` | Log CEF handler dispatch details |
251+| `engine.cef.enable_audio_handler` | bool | `true` | Enable the experimental CEF audio handler |
252+
253+## Privacy
254+
255+| Key | Type | Default | Valid Values | Description |
256+|-----|------|---------|--------------|-------------|
257+| `engine.cookie_policy` | string | `"always"` | `always`, `no_third_party`, `never` | Cookie acceptance policy |
258+| `engine.webkit.itp_enabled` | bool | `true` | - | Enable WebKit fallback Intelligent Tracking Prevention |
259+
248 260
249 ## Rendering, UI Scale & Zoom261 ## Rendering, UI Scale & Zoom
250 262
263+CEF is the default browser engine. WebKitGTK remains available as a fallback via `engine.type = "webkit"`; `engine.webkit.*` settings only affect that fallback engine.
264+
251 | Key | Type | Default | Valid Values | Description |265 | Key | Type | Default | Valid Values | Description |
252 |-----|------|---------|--------------|-------------|266 |-----|------|---------|--------------|-------------|
253-| `rendering.mode` | string | `"gpu"` | `auto`, `gpu`, `cpu` | WebKit hardware acceleration policy |267+| `engine.type` | string | `"cef"` | `cef`, `webkit` | Browser engine selection; WebKitGTK is the fallback option |
254-| `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |
255-| `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
256-| `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |
257-| `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |
258-| `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |
259-| `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |
260-| `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |
261-| `rendering.show_fps` | bool | `false` | - | Show WebKit FPS counter (`WEBKIT_SHOW_FPS`) |
262-| `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
263-| `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |
264-| `engine.type` | string | `"cef"` | `cef`, `webkit` | Browser engine. WebKit remains available as a fallback. |
265 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |268 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
266-| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) |269+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling when `windowless_frame_rate = 0` |
267-| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default |270+| `engine.cef.windowless_frame_rate` | int32 | `0` | `>= 0` | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled |
268-| `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF |271+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | `>= 0` | Hard cap for adaptive CEF OSR FPS; 0 uses the built-in cap |
269-| `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | Mouse wheel scroll sensitivity multiplier |272+| `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | `> 0` | Mouse wheel scroll sensitivity multiplier |
270-| `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | Precise/surface scroll sensitivity multiplier for touchpads and high-resolution wheels |273+| `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | `> 0` | Precise/surface scroll sensitivity multiplier for touchpads and high-resolution wheels |
271-| `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | Horizontal scroll sensitivity multiplier |274+| `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | `> 0` | Horizontal scroll sensitivity multiplier |
272-| `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | Vertical scroll sensitivity multiplier; touchpad vertical speed uses this together with `scroll_precise_multiplier` |275+| `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads |
273-| `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | Maximum absolute scroll delta after scaling; 0 disables clamping |276+| `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping |
274 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation |277 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation |
275-| `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | Minimum accumulated horizontal swipe delta required for navigation |278+| `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation |
276-| `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes; does not affect vertical scroll speed |279+| `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes |
277-| `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |280+| `engine.webkit.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | WebKit fallback GTK renderer selection (`GSK_RENDERER`) |
281+| `engine.webkit.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit fallback DMA-BUF renderer |
282+| `engine.webkit.force_compositing_mode` | bool | `false` | - | Force WebKit fallback compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
283+| `engine.webkit.disable_compositing_mode` | bool | `false` | - | Disable WebKit fallback compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |
284+| `engine.webkit.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps for the WebKit fallback (`GSK_GPU_DISABLE=mipmap`) |
285+| `engine.webkit.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES for the WebKit fallback (`GDK_DEBUG=gl-prefer-gl`) |
286+| `engine.webkit.draw_compositing_indicators` | bool | `false` | - | Draw WebKit fallback compositing indicators (debug) |
287+| `engine.webkit.show_fps` | bool | `false` | - | Show WebKit fallback FPS counter (`WEBKIT_SHOW_FPS`) |
288+| `engine.webkit.sample_memory` | bool | `false` | - | Enable WebKit fallback memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
289+| `engine.webkit.debug_frames` | bool | `false` | - | Enable GTK frame timing debug for the WebKit fallback (`GDK_DEBUG=frames`) |
290+| `engine.webkit.force_vsync` | bool | `false` | - | Force VSync for WebKit fallback video playback |
291+| `engine.webkit.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | WebKit fallback GStreamer/OpenGL API selection |
292+| `engine.webkit.gstreamer_debug_level` | int | `0` | `0-5` | WebKit fallback GStreamer debug verbosity |
293+| `default_ui_scale` | float | `1.0` | `> 0` | GTK widget UI scale (1.0=100%, 2.0=200%) |
294+| `default_webpage_zoom` | float | `1.2` | `> 0` | Default page zoom (1.0=100%, 1.2=120%) |
278 295
279 `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed.296 `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed.
280 297
281-`engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `200.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity.298+`engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity.
282-| `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |299+
300+### Legacy key migration
301+
302+Existing configs using older keys are migrated to the current engine shape. New configs should use the canonical keys above.
303+
304+| Legacy key | Current key |
305+|------------|-------------|
306+| `privacy.cookie_policy` | `engine.cookie_policy` |
307+| `privacy.itp_enabled` | `engine.webkit.itp_enabled` |
308+| `rendering.disable_dmabuf_renderer` | `engine.webkit.disable_dmabuf_renderer` |
309+| `rendering.force_compositing_mode` | `engine.webkit.force_compositing_mode` |
310+| `rendering.disable_compositing_mode` | `engine.webkit.disable_compositing_mode` |
311+| `rendering.gsk_renderer` | `engine.webkit.gsk_renderer` |
312+| `rendering.disable_mipmaps` | `engine.webkit.disable_mipmaps` |
313+| `rendering.prefer_gl` | `engine.webkit.prefer_gl` |
314+| `rendering.draw_compositing_indicators` | `engine.webkit.draw_compositing_indicators` |
315+| `rendering.show_fps` | `engine.webkit.show_fps` |
316+| `rendering.sample_memory` | `engine.webkit.sample_memory` |
317+| `rendering.debug_frames` | `engine.webkit.debug_frames` |
318+| `media.force_vsync` | `engine.webkit.force_vsync` |
319+| `media.gl_rendering_mode` | `engine.webkit.gl_rendering_mode` |
320+| `media.gstreamer_debug_level` | `engine.webkit.gstreamer_debug_level` |
321+| `runtime.prefix` | `engine.webkit.prefix` |
322+| `performance.*` | `engine.profile`, `engine.pool_prewarm_count`, `engine.zoom_cache_size`, or matching `engine.webkit.*` custom tuning keys |
323+| `rendering.mode` | Dropped; no current setting |
283 324
284 ## Workspace Configuration325 ## Workspace Configuration
285 326
@@ -548,21 +589,14 @@ max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup
548 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |589 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
549 | `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |590 | `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
550 | `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |591 | `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |
551-| `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |592+
552-| `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |593+WebKit fallback GStreamer tuning is configured under `engine.webkit.force_vsync`, `engine.webkit.gl_rendering_mode`, and `engine.webkit.gstreamer_debug_level`.
553-| `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |
554 594
555 **Hardware decoding modes:**595 **Hardware decoding modes:**
556 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000596 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000
557 - `force`: Hardware only - fails if unavailable597 - `force`: Hardware only - fails if unavailable
558 - `disable`: Software only - higher CPU usage598 - `disable`: Software only - higher CPU usage
559 599
560-**GL rendering modes:**
561-- `auto` (default): Let GStreamer choose the best OpenGL API
562-- `gles2`: Force GLES2 (better for some mobile/embedded GPUs)
563-- `gl3`: Force OpenGL 3.x desktop
564-- `none`: Disable GL-based rendering
565-
566 **GPU auto-detection:**600 **GPU auto-detection:**
567 Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal VA-API driver settings:601 Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal VA-API driver settings:
568 - **AMD**: Uses `radeonsi` driver602 - **AMD**: Uses `radeonsi` driver
@@ -570,28 +604,25 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
570 - **NVIDIA**: Uses `nvidia` driver with EGL platform604 - **NVIDIA**: Uses `nvidia` driver with EGL platform
571 605
572 **Example:**606 **Example:**
607+
573 ```toml608 ```toml
574 [media]609 [media]
575 hardware_decoding = "auto" # HW preferred, SW fallback610 hardware_decoding = "auto" # HW preferred, SW fallback
576 prefer_av1 = false # Let site choose codec611 prefer_av1 = false # Let site choose codec
577 show_diagnostics = false # Keep off for daily use612 show_diagnostics = false # Keep off for daily use
578-force_vsync = false # Let compositor handle VSync
579-gl_rendering_mode = "auto" # GStreamer picks best GL API
580-gstreamer_debug_level = 0 # Increase to 3-5 for debugging
581 ```613 ```
582 614
583-**Diagnostic mode toggle path:**615+**WebKit fallback GStreamer diagnostics:**
584 616
585 ```toml617 ```toml
586-# ~/.config/dumber/config.toml618+[engine.webkit]
587-[media]
588-show_diagnostics = true
589 gstreamer_debug_level = 3619 gstreamer_debug_level = 3
590 ```620 ```
591 621
592-Set `show_diagnostics = false` and `gstreamer_debug_level = 0` to return to normal mode.622+Set `media.show_diagnostics = false` and `engine.webkit.gstreamer_debug_level = 0` to return to normal mode.
593 623
594 **Diagnostics CLI:**624 **Diagnostics CLI:**
625+
595 ```bash626 ```bash
596 # Check GStreamer plugins and VA-API status627 # Check GStreamer plugins and VA-API status
597 dumber doctor --media628 dumber doctor --media
@@ -601,17 +632,22 @@ dumber doctor --media
601 632
602 | Key | Type | Default | Description |633 | Key | Type | Default | Description |
603 |-----|------|---------|-------------|634 |-----|------|---------|-------------|
604-| `runtime.prefix` | string | `""` | Optional runtime prefix (e.g., `/opt/webkitgtk`) used to locate newer GTK/WebKitGTK installs |635+| `engine.cef.cef_dir` | string | `""` | Optional CEF runtime directory containing `libcef.so` and resources |
636+| `engine.webkit.prefix` | string | `""` | Optional WebKitGTK fallback runtime prefix (e.g., `/opt/webkitgtk`) |
605 637
606-If set, dumber prepends prefix-derived paths to:638+`engine.cef.cef_dir` overrides CEF runtime discovery. For the WebKitGTK fallback, `engine.webkit.prefix` prepends prefix-derived paths to:
607 - `PKG_CONFIG_PATH` (for version checks)639 - `PKG_CONFIG_PATH` (for version checks)
608 - `LD_LIBRARY_PATH` (for runtime library loading)640 - `LD_LIBRARY_PATH` (for runtime library loading)
609 - `GI_TYPELIB_PATH` (GObject introspection)641 - `GI_TYPELIB_PATH` (GObject introspection)
610 - `XDG_DATA_DIRS` (schemas/resources)642 - `XDG_DATA_DIRS` (schemas/resources)
611 643
612 **Example:**644 **Example:**
645+
613 ```toml646 ```toml
614-[runtime]647+[engine.cef]
648+cef_dir = "/opt/cef"
649+
650+[engine.webkit]
615 prefix = "/opt/webkitgtk"651 prefix = "/opt/webkitgtk"
616 ```652 ```
617 653
@@ -624,6 +660,7 @@ prefix = "/opt/webkitgtk"
624 When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.660 When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.
625 661
626 **Example:**662 **Example:**
663+
627 ```toml664 ```toml
628 [clipboard]665 [clipboard]
629 auto_copy_on_selection = true # Enabled by default666 auto_copy_on_selection = true # Enabled by default
@@ -649,6 +686,7 @@ Notes:
649 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |686 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
650 687
651 **Example:**688 **Example:**
689+
652 ```toml690 ```toml
653 [update]691 [update]
654 enable_on_startup = true # Check for updates on startup692 enable_on_startup = true # Check for updates on startup
@@ -657,6 +695,7 @@ notify_on_new_settings = true # Show toast when config migration available
657 ```695 ```
658 696
659 **CLI Commands:**697 **CLI Commands:**
698+
660 ```bash699 ```bash
661 # Check config status and available migrations700 # Check config status and available migrations
662 dumber config status701 dumber config status
@@ -670,89 +709,71 @@ dumber config migrate --yes
670 709
671 ## Performance Profiles710 ## Performance Profiles
672 711
673-Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior.712+Performance profiles tune engine resource behavior. CEF is the default engine; the `engine.webkit.*` custom fields below apply only when using the WebKitGTK fallback.
674 713
675 > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect.714 > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect.
676 715
677 | Key | Type | Default | Valid Values | Description |716 | Key | Type | Default | Valid Values | Description |
678 |-----|------|---------|--------------|-------------|717 |-----|------|---------|--------------|-------------|
679-| `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection |718+| `engine.profile` | string | `"default"` | `default`, `lite`, `balanced`, `max`, `custom` | Performance profile selection |
719+| `engine.pool_prewarm_count` | int | `4` | `>= 0` | WebViews to pre-create at startup |
720+| `engine.zoom_cache_size` | int | `256` | `>= 0` | Domain zoom levels to cache |
680 721
681 ### Profiles722 ### Profiles
682 723
683 | Profile | Description | Use Case |724 | Profile | Description | Use Case |
684 |---------|-------------|----------|725 |---------|-------------|----------|
685-| `default` | No tuning, uses WebKit defaults | Compatibility baseline, troubleshooting |726+| `default` | No tuning, uses engine defaults | Compatibility baseline, troubleshooting |
686 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |727 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
728+| `balanced` | Moderate tuning | General use |
687 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |729 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
688 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |730 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |
689 731
690-### Profile Settings Matrix
691-
692-| Setting | default | lite | max |
693-|---------|---------|------|-----|
694-| Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) |
695-| Skia GPU threads | unset | unset | scales with VRAM |
696-| Web process memory (MB) | unset | 768 | unset |
697-| Network process memory (MB) | unset | 384 | unset |
698-| Conservative threshold | unset | 0.25 | unset |
699-| Strict threshold | unset | 0.4 | unset |
700-| WebView pool prewarm | 4 | 2 | scales with RAM |
701-
702 **Example:**732 **Example:**
703-```toml
704-[performance]
705-profile = "default" # WebKit baseline default
706 733
707-# For WebKit baseline behavior:734+```toml
708-# profile = "default"735+[engine]
709-736+profile = "default"
710-# For heavy pages (GitHub PRs, complex web apps):737+pool_prewarm_count = 4
711-# profile = "max"738+zoom_cache_size = 256
712-
713-# For manual control:
714-# profile = "custom"
715 ```739 ```
716 740
717-### Custom Profile Settings741+### WebKit fallback custom profile settings
718 742
719-When `profile = "custom"`, you can configure individual tuning options:743+When `engine.profile = "custom"` and `engine.type = "webkit"`, you can configure individual WebKit tuning options:
720 744
721 | Key | Type | Default | Description |745 | Key | Type | Default | Description |
722 |-----|------|---------|-------------|746 |-----|------|---------|-------------|
723-| `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) |747+| `engine.webkit.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) |
724-| `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) |748+| `engine.webkit.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) |
725-| `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering |749+| `engine.webkit.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering |
726-| `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) |750+| `engine.webkit.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) |
727-| `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) |751+| `engine.webkit.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) |
728-| `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) |752+| `engine.webkit.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) |
729-| `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) |753+| `engine.webkit.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) |
730-| `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB |754+| `engine.webkit.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB |
731-| `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |755+| `engine.webkit.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |
732-| `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |756+| `engine.webkit.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |
733-| `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |757+| `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
734-| `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup |758+
735-| `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache |759+**Custom WebKit fallback example:**
736-760+
737-**Custom profile example:**
738 ```toml761 ```toml
739-[performance]762+[engine]
763+type = "webkit"
740 profile = "custom"764 profile = "custom"
765+pool_prewarm_count = 6
766+zoom_cache_size = 256
741 767
742-# Skia threading - tune for your CPU768+[engine.webkit]
743 skia_cpu_painting_threads = 4769 skia_cpu_painting_threads = 4
744 skia_gpu_painting_threads = 2770 skia_gpu_painting_threads = 2
745-
746-# Web process memory pressure
747 web_process_memory_limit_mb = 1024771 web_process_memory_limit_mb = 1024
748 web_process_memory_conservative_threshold = 0.4772 web_process_memory_conservative_threshold = 0.4
749 web_process_memory_strict_threshold = 0.6773 web_process_memory_strict_threshold = 0.6
750-
751-# WebView pool
752-webview_pool_prewarm_count = 6
753 ```774 ```
754 775
755-> **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.776+> **Important:** Individual `engine.webkit.*` tuning fields are ignored unless `engine.profile = "custom"`.
756 777
757 ## Downloads778 ## Downloads
758 779
@@ -779,14 +800,14 @@ All config values can be overridden via environment variables with the prefix `D
779 # Database800 # Database
780 DUMBER_DATABASE_PATH=/custom/path/db.sqlite801 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
781 802
782-# Rendering (examples)803+# Engine/rendering (examples)
804+DUMBER_ENGINE_TYPE=cef
783 # CEF defaults to the GPU-first Vulkan DMABUF stack.805 # CEF defaults to the GPU-first Vulkan DMABUF stack.
784 # Use EGL/OpenGL for driver compatibility:806 # Use EGL/OpenGL for driver compatibility:
785 # DUMBER_ENGINE_CEF_RENDER_STACK=egl807 # DUMBER_ENGINE_CEF_RENDER_STACK=egl
786-DUMBER_RENDERING_MODE=cpu808+# WebKit fallback rendering overrides:
787-# WebKit-specific rendering overrides:809+# DUMBER_ENGINE_WEBKIT_DISABLE_DMABUF_RENDERER=true
788-# DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true810+# DUMBER_ENGINE_WEBKIT_GSK_RENDERER=opengl
789-# DUMBER_RENDERING_GSK_RENDERER=opengl
790 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5811 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5
791 812
792 # Logging813 # Logging
M docs/getting-started.md +2 -2
@@ -39,7 +39,7 @@ Press `Ctrl+L` to open the omnibox for:
39 39
40 - Press `Alt+F` to toggle the floating pane.40 - Press `Alt+F` to toggle the floating pane.
41 - Press `Ctrl+W` to close the active pane; if the floating pane is active, this fully releases it so the next open starts fresh.41 - Press `Ctrl+W` to close the active pane; if the floating pane is active, this fully releases it so the next open starts fresh.
42-- Some `Alt+<key>` bindings can conflict with default WebKit shortcuts or desktop-level handlers.42+- Some `Alt+<key>` bindings can conflict with browser-engine shortcuts or desktop-level handlers.
43 43
44 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.44 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.
45 45
@@ -70,7 +70,7 @@ Dumber includes a built-in permissions system for camera, microphone, and screen
70 - **Privacy-focused** - All permissions stored locally70 - **Privacy-focused** - All permissions stored locally
71 - **Camera & Microphone** - Fully working on Wayland/PipeWire71 - **Camera & Microphone** - Fully working on Wayland/PipeWire
72 72
73-> ⚠️ **Note**: Screen sharing does not currently work on Wayland with WebKitGTK 6.0. This is a known WebKitGTK limitation.73+> ⚠️ **WebKit fallback note**: Screen sharing does not currently work on Wayland with WebKitGTK 6.0. This is a known WebKitGTK limitation and does not describe the default CEF backend.
74 74
75 When a website requests camera or microphone access, you'll see a permission dialog with options to allow once, always allow, deny, or always deny.75 When a website requests camera or microphone access, you'll see a permission dialog with options to allow once, always allow, deny, or always deny.
76 76
M docs/index.md +3 -3
@@ -1,6 +1,6 @@
1 # Dumber Documentation1 # Dumber Documentation
2 2
3-Dumber is a keyboard-driven browser for tiling window managers, built with GTK4 and WebKitGTK.3+Dumber is a keyboard-driven browser for tiling window managers, built with GTK4. It uses CEF by default, with WebKitGTK available as a fallback backend.
4 4
5 ## What is Dumber?5 ## What is Dumber?
6 6
@@ -41,5 +41,5 @@ A browser designed for Wayland compositors that works like your favorite termina
41 ## Requirements41 ## Requirements
42 42
43 - Linux with Wayland (Sway, Hyprland, River, Niri, etc.)43 - Linux with Wayland (Sway, Hyprland, River, Niri, etc.)
44-- GTK4 and WebKitGTK 2.42+44+- GTK4, CEF for the default backend, and WebKitGTK for the fallback backend
45-- GStreamer (for media playback)45+- GStreamer (for WebKit media playback and diagnostics)
M docs/installation.md +3 -2
@@ -51,8 +51,9 @@ make build
51 51
52 - Go 1.26+52 - Go 1.26+
53 - GTK4 development libraries53 - GTK4 development libraries
54-- WebKitGTK 6.0 development libraries54+- CEF runtime for the default backend
55-- GStreamer development libraries55+- WebKitGTK 6.0 development/runtime libraries for the fallback backend and runtime checks
56+- GStreamer development libraries for WebKit media playback and diagnostics
56 - Brotli for compressed systemviews assets57 - Brotli for compressed systemviews assets
57 58
58 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.59 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
M docs/reference/configuration.md +75 -35
@@ -31,6 +31,7 @@
31 | `appearance.sans_font` | string | `Fira Sans` | |31 | `appearance.sans_font` | string | `Fira Sans` | |
32 | `appearance.serif_font` | string | `Fira Sans` | |32 | `appearance.serif_font` | string | `Fira Sans` | |
33 | `appearance.monospace_font` | string | `Fira Code` | |33 | `appearance.monospace_font` | string | `Fira Code` | |
34+| `appearance.gtk_font` | string | `Adwaita Sans` | |
34 | `appearance.default_font_size` | int | `16` | |35 | `appearance.default_font_size` | int | `16` | |
35 | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` |36 | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` |
36 | `appearance.external_theme.enabled` | bool | `false` | |37 | `appearance.external_theme.enabled` | bool | `false` | |
@@ -52,18 +53,13 @@
52 | `appearance.dark_palette.accent` | string | `#a8a8a8` | |53 | `appearance.dark_palette.accent` | string | `#a8a8a8` | |
53 | `appearance.dark_palette.border` | string | `#363636` | |54 | `appearance.dark_palette.border` | string | `#363636` | |
54 | `debug.enable_devtools` | bool | `true` | |55 | `debug.enable_devtools` | bool | `true` | |
55-| `rendering.mode` | string | `gpu` | `auto`, `gpu`, `cpu` |56+| `engine.cef.log_file` | string | `` | CEF runtime log path |
56-| `rendering.disable_dmabuf_renderer` | bool | `false` | |57+| `engine.cef.log_severity` | int32 | `0` | `0`, `1`, `2`, `3`, `4`, `99` |
57-| `rendering.force_compositing_mode` | bool | `false` | |58+| `engine.cef.trace_handlers` | bool | `false` | |
58-| `rendering.disable_compositing_mode` | bool | `false` | |59+| `engine.cef.enable_audio_handler` | bool | `true` | experimental |
59-| `rendering.gsk_renderer` | string | `auto` | `auto`, `opengl`, `vulkan`, `cairo` |60+| `engine.type` | string | `cef` | `cef`, `webkit` (CEF default; WebKitGTK fallback) |
60-| `rendering.disable_mipmaps` | bool | `false` | |61+| `engine.cookie_policy` | string | `always` | `always`, `no_third_party`, `never` |
61-| `rendering.prefer_gl` | bool | `false` | |62+| `engine.webkit.itp_enabled` | bool | `true` | WebKit fallback only |
62-| `rendering.draw_compositing_indicators` | bool | `false` | |
63-| `rendering.show_fps` | bool | `false` | |
64-| `rendering.sample_memory` | bool | `false` | |
65-| `rendering.debug_frames` | bool | `false` | |
66-| `engine.type` | string | `cef` | `cef`, `webkit` |
67 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |63 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
68 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |64 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
69 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |65 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
@@ -74,24 +70,40 @@
74 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |70 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |
75 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |71 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |
76 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | |72 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | |
77-| `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 |73+| `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 |
78 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 |74 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 |
75+| `engine.webkit.gsk_renderer` | string | `auto` | `auto`, `opengl`, `vulkan`, `cairo` (WebKit fallback only) |
76+| `engine.webkit.disable_dmabuf_renderer` | bool | `false` | WebKit fallback only |
77+| `engine.webkit.force_compositing_mode` | bool | `false` | WebKit fallback only |
78+| `engine.webkit.disable_compositing_mode` | bool | `false` | WebKit fallback only |
79+| `engine.webkit.disable_mipmaps` | bool | `false` | WebKit fallback only |
80+| `engine.webkit.prefer_gl` | bool | `false` | WebKit fallback only |
81+| `engine.webkit.draw_compositing_indicators` | bool | `false` | WebKit fallback only |
82+| `engine.webkit.show_fps` | bool | `false` | WebKit fallback only |
83+| `engine.webkit.sample_memory` | bool | `false` | WebKit fallback only |
84+| `engine.webkit.debug_frames` | bool | `false` | WebKit fallback only |
85+| `engine.webkit.force_vsync` | bool | `false` | WebKit fallback GStreamer only |
86+| `engine.webkit.gl_rendering_mode` | string | `auto` | `auto`, `gles2`, `gl3`, `none` (WebKit fallback only) |
87+| `engine.webkit.gstreamer_debug_level` | int | `0` | `0-5` (WebKit fallback only) |
79 | `default_ui_scale` | float | `1.0` | > 0 |88 | `default_ui_scale` | float | `1.0` | > 0 |
80-
81-Touchpad vertical scroll speed is controlled by `engine.cef.input.scroll_precise_multiplier` and the additional axis-specific `engine.cef.input.scroll_vertical_multiplier`. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed.
82-
83-`engine.cef.input.touchpad_navigation_min_delta` is measured in raw GTK touchpad surface units. The default `200.0` matches WebKit-style commit distance to reduce accidental back/forward navigation and can be overridden in `config.toml`.
84 | `default_webpage_zoom` | float | `1.2` | > 0 |89 | `default_webpage_zoom` | float | `1.2` | > 0 |
90+| `sidebar_width` | int | `320` | `0` or `280-380` |
85 | `workspace.new_pane_url` | string | `about:blank` | |91 | `workspace.new_pane_url` | string | `about:blank` | |
86 | `workspace.switch_to_tab_on_move` | bool | `true` | |92 | `workspace.switch_to_tab_on_move` | bool | `true` | |
93+| `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` |
94+| `workspace.hide_tab_bar_when_single_tab` | bool | `true` | |
87 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |95 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
88 | `workspace.pane_mode.timeout_ms` | int | `3000` | |96 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
97+| `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
89 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |98 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |
90 | `workspace.tab_mode.timeout_ms` | int | `3000` | |99 | `workspace.tab_mode.timeout_ms` | int | `3000` | |
100+| `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
91 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |101 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
92 | `workspace.resize_mode.timeout_ms` | int | `3000` | |102 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
103+| `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
93 | `workspace.resize_mode.step_percent` | float | `5.0` | |104 | `workspace.resize_mode.step_percent` | float | `5.0` | |
94 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |105 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
106+| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings |
95 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |107 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
96 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |108 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
97 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |109 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
@@ -121,31 +133,59 @@ Touchpad vertical scroll speed is controlled by `engine.cef.input.scroll_precise
121 | `session.max_exited_session_age_days` | int | `7` | |133 | `session.max_exited_session_age_days` | int | `7` | |
122 | `session.session_mode.activation_shortcut` | string | `ctrl+o` | |134 | `session.session_mode.activation_shortcut` | string | `ctrl+o` | |
123 | `session.session_mode.timeout_ms` | int | `3000` | |135 | `session.session_mode.timeout_ms` | int | `3000` | |
136+| `session.session_mode.actions.<action>` | []string | see defaults | session mode key mappings |
124 | `media.hardware_decoding` | string | `auto` | `auto`, `force`, `disable` |137 | `media.hardware_decoding` | string | `auto` | `auto`, `force`, `disable` |
125 | `media.prefer_av1` | bool | `false` | |138 | `media.prefer_av1` | bool | `false` | |
126 | `media.show_diagnostics` | bool | `false` | |139 | `media.show_diagnostics` | bool | `false` | |
127-| `media.force_vsync` | bool | `false` | |140+| `engine.cef.cef_dir` | string | `` | CEF runtime directory |
128-| `media.gl_rendering_mode` | string | `auto` | `auto`, `gles2`, `gl3`, `none` |141+| `engine.webkit.prefix` | string | `` | WebKitGTK fallback runtime prefix |
129-| `media.gstreamer_debug_level` | int | `0` | 0-5 |
130-| `runtime.prefix` | string | `` | |
131 | `clipboard.auto_copy_on_selection` | bool | `true` | |142 | `clipboard.auto_copy_on_selection` | bool | `true` | |
132 | `content_filtering.enabled` | bool | `true` | |143 | `content_filtering.enabled` | bool | `true` | |
133 | `content_filtering.auto_update` | bool | `true` | |144 | `content_filtering.auto_update` | bool | `true` | |
134 | `update.enable_on_startup` | bool | `true` | |145 | `update.enable_on_startup` | bool | `true` | |
135 | `update.auto_download` | bool | `false` | |146 | `update.auto_download` | bool | `false` | |
136 | `update.notify_on_new_settings` | bool | `true` | |147 | `update.notify_on_new_settings` | bool | `true` | |
137-| `performance.profile` | string | `default` | `default`, `lite`, `max`, `custom` |148+| `engine.profile` | string | `default` | `default`, `lite`, `balanced`, `max`, `custom` |
138-| `performance.skia_cpu_painting_threads` | int | `0` | |149+| `engine.webkit.skia_cpu_painting_threads` | int | `0` | >= 0 (WebKit fallback custom profile only) |
139-| `performance.skia_gpu_painting_threads` | int | `-1` | |150+| `engine.webkit.skia_gpu_painting_threads` | int | `-1` | >= -1 (WebKit fallback custom profile only) |
140-| `performance.skia_enable_cpu_rendering` | bool | `false` | |151+| `engine.webkit.skia_enable_cpu_rendering` | bool | `false` | WebKit fallback custom profile only |
141-| `performance.web_process_memory_limit_mb` | int | `0` | |152+| `engine.webkit.web_process_memory_limit_mb` | int | `0` | >= 0 (WebKit fallback custom profile only) |
142-| `performance.web_process_memory_poll_interval_sec` | float | `0` | |153+| `engine.webkit.web_process_memory_poll_interval_sec` | float | `0` | >= 0 (WebKit fallback custom profile only) |
143-| `performance.web_process_memory_conservative_threshold` | float | `0` | |154+| `engine.webkit.web_process_memory_conservative_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
144-| `performance.web_process_memory_strict_threshold` | float | `0` | |155+| `engine.webkit.web_process_memory_strict_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
145-| `performance.network_process_memory_limit_mb` | int | `0` | |156+| `engine.webkit.network_process_memory_limit_mb` | int | `0` | >= 0 (WebKit fallback custom profile only) |
146-| `performance.network_process_memory_poll_interval_sec` | float | `0` | |157+| `engine.webkit.network_process_memory_poll_interval_sec` | float | `0` | >= 0 (WebKit fallback custom profile only) |
147-| `performance.network_process_memory_conservative_threshold` | float | `0` | |158+| `engine.webkit.network_process_memory_conservative_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
148-| `performance.network_process_memory_strict_threshold` | float | `0` | |159+| `engine.webkit.network_process_memory_strict_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
149-| `performance.webview_pool_prewarm_count` | int | `4` | |160+| `engine.pool_prewarm_count` | int | `4` | >= 0 |
150-| `performance.zoom_cache_size` | int | `256` | |161+| `engine.zoom_cache_size` | int | `256` | >= 0 |
151 | `downloads.path` | string | `` | |162 | `downloads.path` | string | `` | |
163+
164+Touchpad vertical scroll speed is controlled by `engine.cef.input.scroll_precise_multiplier` and the additional axis-specific `engine.cef.input.scroll_vertical_multiplier`. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed.
165+
166+`engine.cef.input.touchpad_navigation_min_delta` is measured in raw GTK touchpad surface units. The default `320.0` matches WebKit-style commit distance to reduce accidental back/forward navigation and can be overridden in `config.toml`.
167+
168+CEF is the default engine. WebKitGTK is a fallback selected with `engine.type = "webkit"`; `engine.webkit.*` keys are WebKit-specific.
169+
170+Legacy key migration:
171+
172+| Legacy key | Current key |
173+|------------|-------------|
174+| `privacy.cookie_policy` | `engine.cookie_policy` |
175+| `privacy.itp_enabled` | `engine.webkit.itp_enabled` |
176+| `rendering.disable_dmabuf_renderer` | `engine.webkit.disable_dmabuf_renderer` |
177+| `rendering.force_compositing_mode` | `engine.webkit.force_compositing_mode` |
178+| `rendering.disable_compositing_mode` | `engine.webkit.disable_compositing_mode` |
179+| `rendering.gsk_renderer` | `engine.webkit.gsk_renderer` |
180+| `rendering.disable_mipmaps` | `engine.webkit.disable_mipmaps` |
181+| `rendering.prefer_gl` | `engine.webkit.prefer_gl` |
182+| `rendering.draw_compositing_indicators` | `engine.webkit.draw_compositing_indicators` |
183+| `rendering.show_fps` | `engine.webkit.show_fps` |
184+| `rendering.sample_memory` | `engine.webkit.sample_memory` |
185+| `rendering.debug_frames` | `engine.webkit.debug_frames` |
186+| `media.force_vsync` | `engine.webkit.force_vsync` |
187+| `media.gl_rendering_mode` | `engine.webkit.gl_rendering_mode` |
188+| `media.gstreamer_debug_level` | `engine.webkit.gstreamer_debug_level` |
189+| `runtime.prefix` | `engine.webkit.prefix` |
190+| `performance.*` | `engine.profile`, `engine.pool_prewarm_count`, `engine.zoom_cache_size`, or matching `engine.webkit.*` custom tuning keys |
191+| `rendering.mode` | Dropped; no current setting |
M docs/reference/dependencies.md +3 -2
@@ -61,7 +61,7 @@ sudo pacman -S vulkan-radeon
61 sudo pacman -S nvidia-utils # includes Vulkan61 sudo pacman -S nvidia-utils # includes Vulkan
62 ```62 ```
63 63
64-### GStreamer Plugins (for WebKit Media Playback)64+### GStreamer Plugins (for WebKit fallback media playback)
65 65
66 ```bash66 ```bash
67 sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav67 sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav
@@ -79,10 +79,11 @@ CEF is Dumber's default engine. It uses the GPU-first Wayland stack: GDK DMABUF
79 79
80 ## Debian/Ubuntu80 ## Debian/Ubuntu
81 81
82-### Core GTK4 and WebKit82+### Core GTK4, CEF, and WebKit fallback
83 83
84 ```bash84 ```bash
85 sudo apt install libgtk-4-1 libwebkitgtk-6.0-485 sudo apt install libgtk-4-1 libwebkitgtk-6.0-4
86+# Also provide a CEF runtime for the default backend (package name varies by distribution).
86 ```87 ```
87 88
88 ### VA-API89 ### VA-API
M docs/reference/floating-pane.md +1 -1
@@ -53,5 +53,5 @@ desc = "Open Google in floating pane"
53 - The default install only binds `Alt+F` for floating pane toggle.53 - The default install only binds `Alt+F` for floating pane toggle.
54 - Profile shortcuts are opt-in and empty by default.54 - Profile shortcuts are opt-in and empty by default.
55 - Profile shortcuts support multi-modifier combos using `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).55 - Profile shortcuts support multi-modifier combos using `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
56-- Some `Alt+<key>` shortcuts can conflict with default WebKit shortcuts, website handlers, or your desktop environment.56+- Some `Alt+<key>` shortcuts can conflict with browser-engine shortcuts, website handlers, or your desktop environment.
57 - If a shortcut does not fire in Dumber, bind a different key.57 - If a shortcut does not fire in Dumber, bind a different key.
M docs/reference/keybindings.md +1 -1
@@ -101,7 +101,7 @@ These work outside modal modes:
101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
103 103
104-Warning: some `Alt+<key>` combinations may conflict with default WebKit shortcuts, website handlers, or your desktop environment.104+Warning: some `Alt+<key>` combinations may conflict with browser-engine shortcuts, website handlers, or your desktop environment.
105 If a shortcut does not trigger in Dumber, choose a different keybinding.105 If a shortcut does not trigger in Dumber, choose a different keybinding.
106 106
107 For details, see [Floating Pane](./floating-pane.md).107 For details, see [Floating Pane](./floating-pane.md).
M docs/reference/permissions.md +1 -1
@@ -10,7 +10,7 @@ These permissions are automatically granted without user interaction:
10 10
11 - **Display Capture** (screen sharing) - The XDG Desktop Portal handles the UI11 - **Display Capture** (screen sharing) - The XDG Desktop Portal handles the UI
12 12
13- > ⚠️ **Known Issue**: Screen sharing does **not work** on Wayland with WebKitGTK 2.50.x due to a GStreamer caps negotiation failure ([WebKit bug #306440](https://bugs.webkit.org/show_bug.cgi?id=306440)). This was fixed on January 29, 2026 and backported to the 2.52 stable branch. **Workaround**: Upgrade to WebKitGTK 2.52+ when available from your distribution. Camera and microphone permissions work correctly.13+ > ⚠️ **WebKit fallback known issue**: Screen sharing does **not work** on Wayland with WebKitGTK 2.50.x due to a GStreamer caps negotiation failure ([WebKit bug #306440](https://bugs.webkit.org/show_bug.cgi?id=306440)). This was fixed on January 29, 2026 and backported to the 2.52 stable branch. **Workaround for the WebKit backend**: Upgrade to WebKitGTK 2.52+ when available from your distribution. Camera and microphone permissions work correctly.
14 14
15 - **Device Enumeration** (listing microphones/cameras) - Low security risk15 - **Device Enumeration** (listing microphones/cameras) - Low security risk
16 16
M docs/reference/troubleshooting.md +21 -13
@@ -6,7 +6,7 @@ Run the doctor command to check your system:
6 6
7 ```bash7 ```bash
8 dumber doctor # Full check8 dumber doctor # Full check
9-dumber doctor --runtime # GTK4/WebKitGTK only9+dumber doctor --runtime # GTK and WebKit fallback runtime checks only
10 dumber doctor --media # GStreamer/VA-API only10 dumber doctor --media # GStreamer/VA-API only
11 ```11 ```
12 12
@@ -14,14 +14,13 @@ dumber doctor --media # GStreamer/VA-API only
14 14
15 ### Browser won't start15 ### Browser won't start
16 16
17-**Symptoms:** Error about missing libraries or GTK/WebKitGTK version17+**Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version.
18 18
19 **Solution:**19 **Solution:**
20-1. Run `dumber doctor --runtime`20+1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`.
21-2. Install missing dependencies:21+2. Match the fix to the backend in the error:
22- - Arch: `pacman -S webkitgtk-6.0 gtk4`22+ - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime.
23- - Fedora: `dnf install webkitgtk6.0 gtk4`23+ - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`.
24- - Ubuntu: `apt install libwebkitgtk-6.0-4 libgtk-4-1`
25 24
26 ### Video playback issues25 ### Video playback issues
27 26
@@ -29,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only
29 28
30 **Solution:**29 **Solution:**
31 1. Run `dumber doctor --media`30 1. Run `dumber doctor --media`
32-2. Enable diagnostic mode for startup media warnings:31+2. If the issue occurs with `engine.type = "webkit"`, enable GStreamer startup diagnostics:
33 32
34 ```toml33 ```toml
35 # ~/.config/dumber/config.toml34 # ~/.config/dumber/config.toml
36 [media]35 [media]
37 show_diagnostics = true36 show_diagnostics = true
37+
38+ [engine.webkit]
38 gstreamer_debug_level = 339 gstreamer_debug_level = 3
39 ```40 ```
40 41
42+ For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback.
43+
41 3. Check hardware decoding:44 3. Check hardware decoding:
42 45
43 ```toml46 ```toml
@@ -50,21 +53,24 @@ dumber doctor --media # GStreamer/VA-API only
50 - AMD: `libva-mesa-driver`53 - AMD: `libva-mesa-driver`
51 - Intel: `intel-media-driver`54 - Intel: `intel-media-driver`
52 - NVIDIA: `libva-nvidia-driver`55 - NVIDIA: `libva-nvidia-driver`
53-5. After diagnosing, disable diagnostic mode for quieter daily runs:56+5. After diagnosing the WebKit fallback, disable diagnostic mode for quieter daily runs:
54 57
55 ```toml58 ```toml
56 [media]59 [media]
57 show_diagnostics = false60 show_diagnostics = false
61+
62+ [engine.webkit]
58 gstreamer_debug_level = 063 gstreamer_debug_level = 0
59 ```64 ```
60 65
61-### Screen flickering on Wayland66+### Screen flickering on Wayland with the WebKit fallback
62 67
63-**Symptoms:** Flicker when scrolling or rendering68+**Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"`
64 69
65 **Solution:**70 **Solution:**
71+
66 ```toml72 ```toml
67-[rendering]73+[engine.webkit]
68 disable_dmabuf_renderer = true74 disable_dmabuf_renderer = true
69 ```75 ```
70 76
@@ -73,6 +79,7 @@ disable_dmabuf_renderer = true
73 **Symptoms:** Missing characters, wrong font rendering79 **Symptoms:** Missing characters, wrong font rendering
74 80
75 **Solution:**81 **Solution:**
82+
76 ```toml83 ```toml
77 [appearance]84 [appearance]
78 sans_font = "Your Preferred Font"85 sans_font = "Your Preferred Font"
@@ -85,8 +92,9 @@ default_font_size = 16
85 **Symptoms:** Browser uses too much RAM92 **Symptoms:** Browser uses too much RAM
86 93
87 **Solution:**94 **Solution:**
95+
88 ```toml96 ```toml
89-[performance]97+[engine]
90 profile = "lite" # Reduces resource usage98 profile = "lite" # Reduces resource usage
91 ```99 ```
92 100
A internal/application/port/local_path_resolver.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+import "context"
4+
5+// LocalPathResolver resolves user-provided local filesystem paths.
6+// Implementations may perform filesystem and environment I/O.
7+type LocalPathResolver interface {
8+ ResolveExistingPath(ctx context.Context, input string) (absPath string, ok bool, err error)
9+}
A internal/application/port/mocks/local_path_resolver.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLocalPathResolver creates a new instance of MockLocalPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLocalPathResolver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLocalPathResolver {
19+ mock := &MockLocalPathResolver{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockLocalPathResolver is an autogenerated mock type for the LocalPathResolver type
28+type MockLocalPathResolver struct {
29+ mock.Mock
30+}
31+
32+type MockLocalPathResolver_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockLocalPathResolver) EXPECT() *MockLocalPathResolver_Expecter {
37+ return &MockLocalPathResolver_Expecter{mock: &_m.Mock}
38+}
39+
40+// ResolveExistingPath provides a mock function for the type MockLocalPathResolver
41+func (_mock *MockLocalPathResolver) ResolveExistingPath(ctx context.Context, input string) (string, bool, error) {
42+ ret := _mock.Called(ctx, input)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for ResolveExistingPath")
46+ }
47+
48+ var r0 string
49+ var r1 bool
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok {
52+ return returnFunc(ctx, input)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
55+ r0 = returnFunc(ctx, input)
56+ } else {
57+ r0 = ret.Get(0).(string)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
60+ r1 = returnFunc(ctx, input)
61+ } else {
62+ r1 = ret.Get(1).(bool)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, input)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockLocalPathResolver_ResolveExistingPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveExistingPath'
73+type MockLocalPathResolver_ResolveExistingPath_Call struct {
74+ *mock.Call
75+}
76+
77+// ResolveExistingPath is a helper method to define mock.On call
78+// - ctx context.Context
79+// - input string
80+func (_e *MockLocalPathResolver_Expecter) ResolveExistingPath(ctx any, input any) *MockLocalPathResolver_ResolveExistingPath_Call {
81+ return &MockLocalPathResolver_ResolveExistingPath_Call{Call: _e.mock.On("ResolveExistingPath", ctx, input)}
82+}
83+
84+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx context.Context, input string)) *MockLocalPathResolver_ResolveExistingPath_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call {
103+ _c.Call.Return(s, b, err)
104+ return _c
105+}
106+
107+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) RunAndReturn(run func(ctx context.Context, input string) (string, bool, error)) *MockLocalPathResolver_ResolveExistingPath_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/application/usecase/list_sessions.go +4 -1
@@ -9,6 +9,9 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
12+// ListAllSessionsLimit asks Execute to return every known session.
13+const ListAllSessionsLimit = -1
14+
12 // ListSessionsUseCase handles listing sessions with their state information.15 // ListSessionsUseCase handles listing sessions with their state information.
13 type ListSessionsUseCase struct {16 type ListSessionsUseCase struct {
14 sessionRepo repository.SessionRepository17 sessionRepo repository.SessionRepository
@@ -36,7 +39,7 @@ type ListSessionsOutput struct {
36 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {39 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
37 log := logging.FromContext(ctx)40 log := logging.FromContext(ctx)
38 41
39- if limit <= 0 {42+ if limit == 0 {
40 limit = 5043 limit = 50
41 }44 }
42 45
M internal/application/usecase/manage_panes.go +27 -10
@@ -8,8 +8,8 @@ import (
8 "sort"8 "sort"
9 "strings"9 "strings"
10 10
11+ "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12- domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
@@ -72,15 +72,35 @@ const consumeOrExpelExpelCycleMarker = "_expel_cycle"
72 // ManagePanesUseCase handles pane tree operations.72 // ManagePanesUseCase handles pane tree operations.
73 type ManagePanesUseCase struct {73 type ManagePanesUseCase struct {
74 idGenerator IDGenerator74 idGenerator IDGenerator
75+ normalizer *NavigationURLNormalizer
75 }76 }
76 77
77 // NewManagePanesUseCase creates a new pane management use case.78 // NewManagePanesUseCase creates a new pane management use case.
78-func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {79+func NewManagePanesUseCase(idGenerator IDGenerator, localPaths port.LocalPathResolver) *ManagePanesUseCase {
79 return &ManagePanesUseCase{80 return &ManagePanesUseCase{
80 idGenerator: idGenerator,81 idGenerator: idGenerator,
82+ normalizer: NewNavigationURLNormalizer(localPaths),
81 }83 }
82 }84 }
83 85
86+// NormalizeNavigationURL normalizes user navigation input for pane creation.
87+func (uc *ManagePanesUseCase) NormalizeNavigationURL(ctx context.Context, input string) string {
88+ if uc == nil {
89+ return NewNavigationURLNormalizer(nil).Normalize(ctx, input)
90+ }
91+ return uc.normalizer.normalize(ctx, input)
92+}
93+
94+func (uc *ManagePanesUseCase) newPane(ctx context.Context, paneID entity.PaneID, initialURL string) *entity.Pane {
95+ pane := entity.NewPane(paneID)
96+ if initialURL != "" {
97+ pane.URI = uc.normalizer.normalize(ctx, initialURL)
98+ } else {
99+ pane.URI = "about:blank"
100+ }
101+ return pane
102+}
103+
84 // SplitPaneInput contains parameters for splitting a pane.104 // SplitPaneInput contains parameters for splitting a pane.
85 type SplitPaneInput struct {105 type SplitPaneInput struct {
86 Workspace *entity.Workspace106 Workspace *entity.Workspace
@@ -136,12 +156,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
136 newPane = input.NewPane156 newPane = input.NewPane
137 } else {157 } else {
138 paneID := entity.PaneID(uc.idGenerator())158 paneID := entity.PaneID(uc.idGenerator())
139- newPane = entity.NewPane(paneID)159+ newPane = uc.newPane(ctx, paneID, input.InitialURL)
140- if input.InitialURL != "" {
141- newPane.URI = domainurl.Normalize(input.InitialURL)
142- } else {
143- newPane.URI = "about:blank"
144- }
145 }160 }
146 161
147 // Create new pane node162 // Create new pane node
@@ -982,6 +997,7 @@ func (uc *ManagePanesUseCase) CreateStack(
982 ctx context.Context,997 ctx context.Context,
983 ws *entity.Workspace,998 ws *entity.Workspace,
984 paneNode *entity.PaneNode,999 paneNode *entity.PaneNode,
1000+ initialURL string,
985 ) (*CreateStackOutput, error) {1001 ) (*CreateStackOutput, error) {
986 log := logging.FromContext(ctx)1002 log := logging.FromContext(ctx)
987 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")1003 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
@@ -1012,7 +1028,7 @@ func (uc *ManagePanesUseCase) CreateStack(
1012 1028
1013 // Create new pane1029 // Create new pane
1014 newPaneID := entity.PaneID(uc.idGenerator())1030 newPaneID := entity.PaneID(uc.idGenerator())
1015- newPane := entity.NewPane(newPaneID)1031+ newPane := uc.newPane(ctx, newPaneID, initialURL)
1016 1032
1017 // Create child node for the new pane1033 // Create child node for the new pane
1018 newChildNode := &entity.PaneNode{1034 newChildNode := &entity.PaneNode{
@@ -1057,6 +1073,7 @@ func (uc *ManagePanesUseCase) AddToStack(
1057 ws *entity.Workspace,1073 ws *entity.Workspace,
1058 stackNode *entity.PaneNode,1074 stackNode *entity.PaneNode,
1059 pane *entity.Pane,1075 pane *entity.Pane,
1076+ initialURL string,
1060 ) (*AddToStackOutput, error) {1077 ) (*AddToStackOutput, error) {
1061 log := logging.FromContext(ctx)1078 log := logging.FromContext(ctx)
1062 1079
@@ -1073,7 +1090,7 @@ func (uc *ManagePanesUseCase) AddToStack(
1073 // Create pane if not provided1090 // Create pane if not provided
1074 if pane == nil {1091 if pane == nil {
1075 paneID := entity.PaneID(uc.idGenerator())1092 paneID := entity.PaneID(uc.idGenerator())
1076- pane = entity.NewPane(paneID)1093+ pane = uc.newPane(ctx, paneID, initialURL)
1077 }1094 }
1078 1095
1079 log.Debug().1096 log.Debug().
M internal/application/usecase/manage_panes_close_stack_test.go +7 -7
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *testing.T) {10 func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *testing.T) {
11- uc := NewManagePanesUseCase(func() string { return "id" })11+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
12 ctx := context.Background()12 ctx := context.Background()
13 13
14 // Create a stack with 3 panes14 // Create a stack with 3 panes
@@ -44,7 +44,7 @@ func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *
44 }44 }
45 45
46 func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T) {46 func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T) {
47- uc := NewManagePanesUseCase(func() string { return "id" })47+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
48 ctx := context.Background()48 ctx := context.Background()
49 49
50 // Create a stack with 2 panes50 // Create a stack with 2 panes
@@ -84,7 +84,7 @@ func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T)
84 }84 }
85 85
86 func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(t *testing.T) {86 func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(t *testing.T) {
87- uc := NewManagePanesUseCase(func() string { return "id" })87+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
88 ctx := context.Background()88 ctx := context.Background()
89 89
90 // Create a stack with 3 panes, middle one is active90 // Create a stack with 3 panes, middle one is active
@@ -113,7 +113,7 @@ func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(
113 }113 }
114 114
115 func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonActive(t *testing.T) {115 func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonActive(t *testing.T) {
116- uc := NewManagePanesUseCase(func() string { return "id" })116+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
117 ctx := context.Background()117 ctx := context.Background()
118 118
119 // Create a stack with 3 panes, first one is active119 // Create a stack with 3 panes, first one is active
@@ -138,7 +138,7 @@ func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonAc
138 }138 }
139 139
140 func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T) {140 func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T) {
141- uc := NewManagePanesUseCase(func() string { return "id" })141+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
142 ctx := context.Background()142 ctx := context.Background()
143 143
144 // Create a split with a stack on the left144 // Create a split with a stack on the left
@@ -185,7 +185,7 @@ func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T)
185 }185 }
186 186
187 func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *testing.T) {187 func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *testing.T) {
188- uc := NewManagePanesUseCase(func() string { return "id" })188+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
189 ctx := context.Background()189 ctx := context.Background()
190 190
191 // Create a stack with 3 panes, first one is active191 // Create a stack with 3 panes, first one is active
@@ -218,7 +218,7 @@ func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *tes
218 }218 }
219 219
220 func TestManagePanesUseCase_CloseStackedPane_ClosingLastPaneUpdatesIndex(t *testing.T) {220 func TestManagePanesUseCase_CloseStackedPane_ClosingLastPaneUpdatesIndex(t *testing.T) {
221- uc := NewManagePanesUseCase(func() string { return "id" })221+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
222 ctx := context.Background()222 ctx := context.Background()
223 223
224 // Create a stack with 3 panes, last one is active224 // Create a stack with 3 panes, last one is active
M internal/application/usecase/manage_panes_consume_expel_test.go +11 -11
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {10 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11- uc := NewManagePanesUseCase(func() string { return "id" })11+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
12 ctx := context.Background()12 ctx := context.Background()
13 13
14 left := leaf("left")14 left := leaf("left")
@@ -61,7 +61,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVertica
61 }61 }
62 62
63 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {63 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64- uc := NewManagePanesUseCase(func() string { return "id" })64+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
65 ctx := context.Background()65 ctx := context.Background()
66 66
67 left := leaf("left")67 left := leaf("left")
@@ -114,7 +114,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVertic
114 }114 }
115 115
116 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {116 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117- uc := NewManagePanesUseCase(func() string { return "id" })117+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
118 ctx := context.Background()118 ctx := context.Background()
119 119
120 stackNode := stack(leaf("a"), leaf("b"))120 stackNode := stack(leaf("a"), leaf("b"))
@@ -141,7 +141,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
141 }141 }
142 142
143 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {143 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144- uc := NewManagePanesUseCase(func() string { return "id" })144+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
145 ctx := context.Background()145 ctx := context.Background()
146 146
147 top := leaf("top")147 top := leaf("top")
@@ -165,7 +165,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStacke
165 }165 }
166 166
167 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {167 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168- uc := NewManagePanesUseCase(func() string { return "id" })168+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
169 ctx := context.Background()169 ctx := context.Background()
170 170
171 stackNode := stack(leaf("a"), leaf("b"))171 stackNode := stack(leaf("a"), leaf("b"))
@@ -200,7 +200,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin
200 }200 }
201 201
202 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {202 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203- uc := NewManagePanesUseCase(func() string { return "id" })203+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
204 ctx := context.Background()204 ctx := context.Background()
205 205
206 stackNode := stack(leaf("a"), leaf("b"))206 stackNode := stack(leaf("a"), leaf("b"))
@@ -228,7 +228,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalT
228 }228 }
229 229
230 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {230 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231- uc := NewManagePanesUseCase(func() string { return "id" })231+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
232 ctx := context.Background()232 ctx := context.Background()
233 233
234 stackNode := stack(leaf("a"), leaf("b"))234 stackNode := stack(leaf("a"), leaf("b"))
@@ -259,7 +259,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t
259 }259 }
260 260
261 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {261 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262- uc := NewManagePanesUseCase(func() string { return "id" })262+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
263 ctx := context.Background()263 ctx := context.Background()
264 264
265 stackNode := stack(leaf("a"), leaf("b"), leaf("c"))265 stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
@@ -293,7 +293,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *
293 }293 }
294 294
295 func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {295 func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296- uc := NewManagePanesUseCase(func() string { return "id" })296+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
297 ctx := context.Background()297 ctx := context.Background()
298 298
299 left := leaf("left")299 left := leaf("left")
@@ -317,7 +317,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *te
317 }317 }
318 318
319 func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {319 func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320- uc := NewManagePanesUseCase(func() string { return "id" })320+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
321 ctx := context.Background()321 ctx := context.Background()
322 322
323 a := leaf("a")323 a := leaf("a")
@@ -346,7 +346,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSibling
346 }346 }
347 347
348 func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {348 func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349- uc := NewManagePanesUseCase(func() string { return "id" })349+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
350 ctx := context.Background()350 ctx := context.Background()
351 351
352 only := leaf("only")352 only := leaf("only")
M internal/application/usecase/manage_panes_resize_test.go +5 -5
@@ -9,7 +9,7 @@ import (
9 )9 )
10 10
11 func TestManagePanesUseCase_Resize_Errors(t *testing.T) {11 func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
12- uc := NewManagePanesUseCase(func() string { return "id" })12+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
13 13
14 ctx := context.Background()14 ctx := context.Background()
15 15
@@ -33,7 +33,7 @@ func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
33 }33 }
34 34
35 func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {35 func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
36- uc := NewManagePanesUseCase(func() string { return "id" })36+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
37 ctx := context.Background()37 ctx := context.Background()
38 38
39 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}39 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
@@ -67,7 +67,7 @@ func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
67 }67 }
68 68
69 func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {69 func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {
70- uc := NewManagePanesUseCase(func() string { return "id" })70+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
71 ctx := context.Background()71 ctx := context.Background()
72 72
73 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}73 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
@@ -101,7 +101,7 @@ func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testi
101 }101 }
102 102
103 func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {103 func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
104- uc := NewManagePanesUseCase(func() string { return "id" })104+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
105 ctx := context.Background()105 ctx := context.Background()
106 106
107 left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}107 left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}
@@ -127,7 +127,7 @@ func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
127 }127 }
128 128
129 func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {129 func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {
130- uc := NewManagePanesUseCase(func() string { return "id" })130+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
131 ctx := context.Background()131 ctx := context.Background()
132 132
133 stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}}133 stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}}
M internal/application/usecase/manage_panes_split_test.go +2 -2
@@ -9,7 +9,7 @@ import (
9 )9 )
10 10
11 func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {11 func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
12- uc := NewManagePanesUseCase(func() string { return "id" })12+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
13 ctx := context.Background()13 ctx := context.Background()
14 14
15 dirs := []SplitDirection{SplitLeft, SplitRight, SplitUp, SplitDown}15 dirs := []SplitDirection{SplitLeft, SplitRight, SplitUp, SplitDown}
@@ -51,7 +51,7 @@ func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
51 }51 }
52 52
53 func TestManagePanesUseCase_Split_InvalidDirection_ReturnsError(t *testing.T) {53 func TestManagePanesUseCase_Split_InvalidDirection_ReturnsError(t *testing.T) {
54- uc := NewManagePanesUseCase(func() string { return "id" })54+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
55 ctx := context.Background()55 ctx := context.Background()
56 56
57 target := leaf("target")57 target := leaf("target")
M internal/application/usecase/manage_tabs.go +6 -4
@@ -4,8 +4,8 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
@@ -15,12 +15,14 @@ type IDGenerator func() string
15 // ManageTabsUseCase handles tab lifecycle operations.15 // ManageTabsUseCase handles tab lifecycle operations.
16 type ManageTabsUseCase struct {16 type ManageTabsUseCase struct {
17 idGenerator IDGenerator17 idGenerator IDGenerator
18+ normalizer *NavigationURLNormalizer
18 }19 }
19 20
20 // NewManageTabsUseCase creates a new tab management use case.21 // NewManageTabsUseCase creates a new tab management use case.
21-func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {22+func NewManageTabsUseCase(idGenerator IDGenerator, localPaths port.LocalPathResolver) *ManageTabsUseCase {
22 return &ManageTabsUseCase{23 return &ManageTabsUseCase{
23 idGenerator: idGenerator,24 idGenerator: idGenerator,
25+ normalizer: NewNavigationURLNormalizer(localPaths),
24 }26 }
25 }27 }
26 28
@@ -58,7 +60,7 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (
58 // Create initial pane with normalized URL60 // Create initial pane with normalized URL
59 pane := entity.NewPane(paneID)61 pane := entity.NewPane(paneID)
60 if input.InitialURL != "" {62 if input.InitialURL != "" {
61- pane.URI = url.Normalize(input.InitialURL)63+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
62 }64 }
63 65
64 // Create tab with workspace66 // Create tab with workspace
@@ -355,7 +357,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab
355 // Use the provided pane357 // Use the provided pane
356 pane := input.Pane358 pane := input.Pane
357 if input.InitialURL != "" {359 if input.InitialURL != "" {
358- pane.URI = url.Normalize(input.InitialURL)360+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
359 }361 }
360 362
361 // Create tab with workspace using the pre-created pane363 // Create tab with workspace using the pre-created pane
A internal/application/usecase/navigation_url_normalizer.go +101 -0
@@ -0,0 +1,101 @@
1+package usecase
2+
3+import (
4+ "context"
5+ stdurl "net/url"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ domainurl "github.com/bnema/dumber/internal/domain/url"
10+)
11+
12+// NavigationURLNormalizer normalizes user navigation input while keeping
13+// filesystem probing behind an application port.
14+type NavigationURLNormalizer struct {
15+ localPaths port.LocalPathResolver
16+}
17+
18+// NewNavigationURLNormalizer creates a navigation URL normalizer.
19+func NewNavigationURLNormalizer(localPaths port.LocalPathResolver) *NavigationURLNormalizer {
20+ return &NavigationURLNormalizer{localPaths: localPaths}
21+}
22+
23+// Normalize converts existing local paths to file:// URLs, then falls back to
24+// the pure domain URL normalizer for URL-like decisions.
25+func (n *NavigationURLNormalizer) Normalize(ctx context.Context, input string) string {
26+ if input == "" {
27+ return ""
28+ }
29+ if n != nil && n.localPaths != nil && shouldProbeLocalPath(input) {
30+ if absPath, ok, err := n.localPaths.ResolveExistingPath(ctx, input); err == nil && ok {
31+ return (&stdurl.URL{Scheme: "file", Path: absPath}).String()
32+ }
33+ }
34+ return domainurl.Normalize(input)
35+}
36+
37+// BuildNavigationURL resolves local paths before applying bang shortcuts and search fallback.
38+func (n *NavigationURLNormalizer) BuildNavigationURL(
39+ ctx context.Context,
40+ input string,
41+ shortcutURLs map[string]string,
42+ defaultSearch string,
43+) string {
44+ return BuildNavigationURL(ctx, input, n.normalize, shortcutURLs, defaultSearch)
45+}
46+
47+// BuildNavigationURL resolves a user navigation string using the shared CLI/UI policy.
48+func BuildNavigationURL(
49+ ctx context.Context,
50+ input string,
51+ normalize func(context.Context, string) string,
52+ shortcutURLs map[string]string,
53+ defaultSearch string,
54+) string {
55+ if _, _, found := domainurl.ParseBangShortcut(input); !found && normalize != nil {
56+ normalized := normalize(ctx, input)
57+ if normalized != input {
58+ return normalized
59+ }
60+ }
61+ return domainurl.BuildSearchURL(input, shortcutURLs, defaultSearch)
62+}
63+
64+func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string {
65+ if n == nil {
66+ return domainurl.Normalize(input)
67+ }
68+ return n.Normalize(ctx, input)
69+}
70+
71+func shouldProbeLocalPath(input string) bool {
72+ if input == "" {
73+ return false
74+ }
75+ if hasPreservedNavigationScheme(input) {
76+ return false
77+ }
78+ return true
79+}
80+
81+func hasPreservedNavigationScheme(input string) bool {
82+ parsed, err := stdurl.Parse(input)
83+ if err != nil || parsed.Scheme == "" {
84+ return false
85+ }
86+
87+ scheme := strings.ToLower(parsed.Scheme)
88+ lowerInput := strings.ToLower(input)
89+
90+ switch scheme {
91+ case "http", "https", "dumb", "file", "vscode", "vscode-insiders", "spotify", "steam":
92+ return strings.HasPrefix(lowerInput, scheme+"://") && domainurl.Normalize(input) == input
93+ case "blob":
94+ return strings.HasPrefix(lowerInput, "blob:") && strings.Contains(lowerInput, "://") &&
95+ domainurl.Normalize(input) == input
96+ case "about", "data", "javascript", "mailto":
97+ return strings.HasPrefix(lowerInput, scheme+":") && domainurl.Normalize(input) == input
98+ default:
99+ return false
100+ }
101+}
A internal/application/usecase/navigation_url_normalizer_test.go +119 -0
@@ -0,0 +1,119 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ stdurl "net/url"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+type spyLocalPathResolver struct {
12+ calls int
13+}
14+
15+func (r *spyLocalPathResolver) ResolveExistingPath(_ context.Context, _ string) (string, bool, error) {
16+ r.calls++
17+ return filepath.Join(string(filepath.Separator), "tmp", "should-not-be-used.html"), true, nil
18+}
19+
20+func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
21+ ctx := context.Background()
22+ absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
23+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
24+ absFile: absFile,
25+ "page.html": absFile,
26+ "~/page.html": absFile,
27+ "notes:2026.txt": absFile,
28+ "http:notes.txt": absFile,
29+ "http:notes": absFile,
30+ }})
31+
32+ for _, input := range []string{absFile, "page.html", "~/page.html", "notes:2026.txt", "http:notes.txt", "http:notes"} {
33+ t.Run(input, func(t *testing.T) {
34+ got := normalizer.Normalize(ctx, input)
35+ want := (&stdurl.URL{Scheme: "file", Path: absFile}).String()
36+ if got != want {
37+ t.Fatalf("Normalize(%q) = %q, want %q", input, got, want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) {
44+ ctx := context.Background()
45+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{err: errors.New("boom")})
46+
47+ if got := normalizer.Normalize(ctx, "example.com"); got != "https://example.com" {
48+ t.Fatalf("Normalize(example.com) = %q", got)
49+ }
50+ if got := normalizer.Normalize(ctx, "./missing.html"); got != "./missing.html" {
51+ t.Fatalf("Normalize(./missing.html) = %q", got)
52+ }
53+}
54+func TestNavigationURLNormalizerBuildNavigationURLCentralizesSearchFallback(t *testing.T) {
55+ ctx := context.Background()
56+ absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
57+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
58+ "page.html": absFile,
59+ }})
60+ shortcuts := map[string]string{"g": "https://google.com/search?q=%s"}
61+
62+ if got := normalizer.BuildNavigationURL(ctx, "page.html", shortcuts, "https://search.example/?q=%s"); got != "file://"+absFile {
63+ t.Fatalf("BuildNavigationURL(local file) = %q, want file URL", got)
64+ }
65+ if got := normalizer.BuildNavigationURL(ctx, "!g cats", shortcuts, "https://search.example/?q=%s"); got != "https://google.com/search?q=cats" {
66+ t.Fatalf("BuildNavigationURL(shortcut) = %q, want shortcut search", got)
67+ }
68+}
69+
70+func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) {
71+ ctx := context.Background()
72+
73+ tests := []struct {
74+ name string
75+ input string
76+ want string
77+ }{
78+ {
79+ name: "https scheme",
80+ input: "https://example.com/page",
81+ want: "https://example.com/page",
82+ },
83+ {
84+ name: "file scheme",
85+ input: "file:///tmp/page.html",
86+ want: "file:///tmp/page.html",
87+ },
88+ {
89+ name: "about scheme",
90+ input: "about:blank",
91+ want: "about:blank",
92+ },
93+ {
94+ name: "external scheme without slashes",
95+ input: "mailto:user@example.com",
96+ want: "mailto:user@example.com",
97+ },
98+ {
99+ name: "data scheme",
100+ input: "data:text/plain,hello",
101+ want: "data:text/plain,hello",
102+ },
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ resolver := &spyLocalPathResolver{}
108+ normalizer := NewNavigationURLNormalizer(resolver)
109+
110+ got := normalizer.Normalize(ctx, tt.input)
111+ if got != tt.want {
112+ t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
113+ }
114+ if resolver.calls != 0 {
115+ t.Fatalf("ResolveExistingPath called %d times for %q, want 0", resolver.calls, tt.input)
116+ }
117+ })
118+ }
119+}
A internal/application/usecase/navigation_url_usecase_integration_test.go +143 -0
@@ -0,0 +1,143 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+type fakeLocalPathResolver struct {
12+ paths map[string]string
13+ err error
14+}
15+
16+func (r fakeLocalPathResolver) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
17+ if r.err != nil {
18+ return "", false, r.err
19+ }
20+ abs, ok := r.paths[input]
21+ return abs, ok, nil
22+}
23+
24+func TestManageTabsCreateResolvesExistingLocalInitialURL(t *testing.T) {
25+ ctx := context.Background()
26+ tabs := entity.NewTabList()
27+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "tabs.html")
28+ uc := NewManageTabsUseCase(
29+ sequentialIDGenerator("tab", "workspace", "pane"),
30+ fakeLocalPathResolver{paths: map[string]string{"tabs.html": tabsFile}},
31+ )
32+
33+ out, err := uc.Create(ctx, CreateTabInput{TabList: tabs, InitialURL: "tabs.html"})
34+ if err != nil {
35+ t.Fatalf("Create returned error: %v", err)
36+ }
37+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
38+ t.Fatalf("created pane URI = %q, want %q", got, want)
39+ }
40+}
41+
42+func TestManageTabsCreateWithPaneResolvesExistingLocalInitialURL(t *testing.T) {
43+ ctx := context.Background()
44+ tabs := entity.NewTabList()
45+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "provided.html")
46+ uc := NewManageTabsUseCase(
47+ sequentialIDGenerator("tab", "workspace"),
48+ fakeLocalPathResolver{paths: map[string]string{"provided.html": tabsFile}},
49+ )
50+ pane := entity.NewPane("provided-pane")
51+
52+ out, err := uc.CreateWithPane(ctx, CreateTabWithPaneInput{TabList: tabs, Pane: pane, InitialURL: "provided.html"})
53+ if err != nil {
54+ t.Fatalf("CreateWithPane returned error: %v", err)
55+ }
56+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
57+ t.Fatalf("provided pane URI = %q, want %q", got, want)
58+ }
59+}
60+
61+func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) {
62+ ctx := context.Background()
63+ paneFile := filepath.Join(string(filepath.Separator), "tmp", "split.html")
64+ uc := NewManagePanesUseCase(
65+ sequentialIDGenerator("new-pane", "parent"),
66+ fakeLocalPathResolver{paths: map[string]string{"split.html": paneFile}},
67+ )
68+ active := entity.NewPane("active-pane")
69+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
70+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
71+
72+ out, err := uc.Split(ctx, SplitPaneInput{
73+ Workspace: workspace,
74+ TargetPane: activeNode,
75+ Direction: SplitRight,
76+ InitialURL: "split.html",
77+ })
78+ if err != nil {
79+ t.Fatalf("Split returned error: %v", err)
80+ }
81+ if got, want := out.NewPaneNode.Pane.URI, "file://"+paneFile; got != want {
82+ t.Fatalf("split pane URI = %q, want %q", got, want)
83+ }
84+}
85+
86+func TestManagePanesCreateStackResolvesExistingLocalInitialURL(t *testing.T) {
87+ ctx := context.Background()
88+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "stack.html")
89+ uc := NewManagePanesUseCase(
90+ sequentialIDGenerator("new-pane"),
91+ fakeLocalPathResolver{paths: map[string]string{"stack.html": stackFile}},
92+ )
93+ active := entity.NewPane("active-pane")
94+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
95+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
96+
97+ out, err := uc.CreateStack(ctx, workspace, activeNode, "stack.html")
98+ if err != nil {
99+ t.Fatalf("CreateStack returned error: %v", err)
100+ }
101+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
102+ t.Fatalf("stack pane URI = %q, want %q", got, want)
103+ }
104+}
105+
106+func TestManagePanesAddToStackResolvesExistingLocalInitialURL(t *testing.T) {
107+ ctx := context.Background()
108+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "added.html")
109+ uc := NewManagePanesUseCase(
110+ sequentialIDGenerator("added-pane"),
111+ fakeLocalPathResolver{paths: map[string]string{"added.html": stackFile}},
112+ )
113+ active := entity.NewPane("active-pane")
114+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
115+ stackNode := &entity.PaneNode{
116+ ID: "stack",
117+ IsStacked: true,
118+ ActiveStackIndex: 0,
119+ Children: []*entity.PaneNode{activeNode},
120+ }
121+ activeNode.Parent = stackNode
122+ workspace := &entity.Workspace{ID: "workspace", Root: stackNode, ActivePaneID: active.ID}
123+
124+ out, err := uc.AddToStack(ctx, workspace, stackNode, nil, "added.html")
125+ if err != nil {
126+ t.Fatalf("AddToStack returned error: %v", err)
127+ }
128+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
129+ t.Fatalf("added stack pane URI = %q, want %q", got, want)
130+ }
131+}
132+
133+func sequentialIDGenerator(ids ...string) IDGenerator {
134+ idx := 0
135+ return func() string {
136+ if idx >= len(ids) {
137+ return "extra-id"
138+ }
139+ id := ids[idx]
140+ idx++
141+ return id
142+ }
143+}
M internal/bootstrap/gui.go +1 -1
@@ -227,7 +227,7 @@ func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
227 }227 }
228 }228 }
229 229
230-// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.230+// CheckRuntimeRequirements verifies GUI runtime dependencies currently covered by runtime checks.
231 // Returns error if requirements are not met; caller should log details and exit.231 // Returns error if requirements are not met; caller should log details and exit.
232 // Note: When running in a Flatpak sandbox, runtime checks are skipped because232 // Note: When running in a Flatpak sandbox, runtime checks are skipped because
233 // the Flatpak runtime provides all required libraries.233 // the Flatpak runtime provides all required libraries.
M internal/bootstrap/runtime_profile_test.go +10 -1
@@ -1,7 +1,9 @@
1 package bootstrap1 package bootstrap
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
6+ "strings"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -52,12 +54,19 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
52 wd := t.TempDir()54 wd := t.TempDir()
53 t.Chdir(wd)55 t.Chdir(wd)
54 56
57+ runtimeDir, err := os.MkdirTemp("", "dbr-")
58+ require.NoError(t, err)
59+ t.Cleanup(func() {
60+ _ = os.RemoveAll(runtimeDir)
61+ })
55 t.Setenv("ENV", "dev")62 t.Setenv("ENV", "dev")
63+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
56 profile, err := ResolveRuntimeProfile(cfg)64 profile, err := ResolveRuntimeProfile(cfg)
57 require.NoError(t, err)65 require.NoError(t, err)
58 require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir)66 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())67 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)68+ require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, filepath.Join(runtimeDir, "dumber", "dev-")))
69+ require.Contains(t, profile.IPC.BrowserLaunchSocket, filepath.Join("cef", "browser-launch.sock"))
61 }70 }
62 71
63 func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) {72 func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) {
M internal/cli/app.go +21 -15
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
20 "github.com/bnema/dumber/internal/infrastructure/favicon"20 "github.com/bnema/dumber/internal/infrastructure/favicon"
21+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
21 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"22 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
22 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"23 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
23 "github.com/bnema/dumber/internal/logging"24 "github.com/bnema/dumber/internal/logging"
@@ -39,8 +40,10 @@ type App struct {
39 DeleteSessionUC *usecase.DeleteSessionUseCase40 DeleteSessionUC *usecase.DeleteSessionUseCase
40 41
41 // Services42 // Services
42- FaviconService *favicon.Service43+ FaviconService *favicon.Service
43- SessionSpawner port.SessionSpawner44+ SessionSpawner port.SessionSpawner
45+ LocalPaths port.LocalPathResolver
46+ NavigationURLNormalizer *usecase.NavigationURLNormalizer
44 47
45 // Context with logger48 // Context with logger
46 ctx context.Context49 ctx context.Context
@@ -128,21 +131,24 @@ func NewApp() (*App, error) {
128 // Create favicon service for CLI (path resolution for dmenu/fuzzel)131 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
129 faviconCacheDir, _ := config.GetFaviconCacheDir()132 faviconCacheDir, _ := config.GetFaviconCacheDir()
130 faviconService := favicon.NewService(faviconCacheDir)133 faviconService := favicon.NewService(faviconCacheDir)
134+ localPaths := filesystem.New()
131 135
132 return &App{136 return &App{
133- Config: cfg,137+ Config: cfg,
134- Theme: theme,138+ Theme: theme,
135- db: db,139+ db: db,
136- History: historyRepo,140+ History: historyRepo,
137- SearchHistoryUC: searchHistoryUC,141+ SearchHistoryUC: searchHistoryUC,
138- SessionUC: sessionUC,142+ SessionUC: sessionUC,
139- ListSessionsUC: listSessionsUC,143+ ListSessionsUC: listSessionsUC,
140- RestoreUC: restoreUC,144+ RestoreUC: restoreUC,
141- DeleteSessionUC: deleteSessionUC,145+ DeleteSessionUC: deleteSessionUC,
142- FaviconService: faviconService,146+ FaviconService: faviconService,
143- SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),147+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
144- ctx: ctx,148+ LocalPaths: localPaths,
145- logCleanup: logCleanup,149+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150+ ctx: ctx,
151+ logCleanup: logCleanup,
146 }, nil152 }, nil
147 }153 }
148 154
M internal/cli/cmd/dmenu.go +13 -2
@@ -12,6 +12,7 @@ import (
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14 "github.com/bnema/dumber/assets"14 "github.com/bnema/dumber/assets"
15+ "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/cli/model"16 "github.com/bnema/dumber/internal/cli/model"
16 "github.com/bnema/dumber/internal/domain/entity"17 "github.com/bnema/dumber/internal/domain/entity"
17 domainurl "github.com/bnema/dumber/internal/domain/url"18 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -217,10 +218,20 @@ func parseSelection(selection string) string {
217 return ""218 return ""
218 }219 }
219 220
220- // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries221+ // Existing local paths are resolved before URL/search fallback so dmenu keeps
222+ // the same file:// behavior as the in-app omnibox.
221 app := GetApp()223 app := GetApp()
222 if app != nil {224 if app != nil {
223- return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)225+ normalizer := app.NavigationURLNormalizer
226+ if normalizer == nil {
227+ normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths)
228+ }
229+ return normalizer.BuildNavigationURL(
230+ context.Background(),
231+ selection,
232+ app.Config.ShortcutURLs(),
233+ app.Config.DefaultSearchEngine,
234+ )
224 }235 }
225 236
226 // Fallback: add https:// scheme if no app context237 // Fallback: add https:// scheme if no app context
A internal/cli/cmd/dmenu_test.go +53 -0
@@ -0,0 +1,53 @@
1+package cmd
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/cli"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
12+)
13+
14+func TestParseSelectionResolvesExistingLocalFile(t *testing.T) {
15+ oldApp := app
16+ t.Cleanup(func() { app = oldApp })
17+ localPaths := filesystem.New()
18+ app = &cli.App{
19+ Config: config.DefaultConfig(),
20+ LocalPaths: localPaths,
21+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
22+ }
23+
24+ tmpDir := t.TempDir()
25+ file := filepath.Join(tmpDir, "page.html")
26+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
27+ t.Fatal(err)
28+ }
29+ oldWD, err := os.Getwd()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.Chdir(tmpDir); err != nil {
34+ t.Fatal(err)
35+ }
36+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
37+
38+ if got := parseSelection("page.html"); got != "file://"+file {
39+ t.Fatalf("parseSelection(page.html) = %q, want file URL", got)
40+ }
41+}
42+
43+func TestParseSelectionLeavesBangShortcutsToSearchHandling(t *testing.T) {
44+ oldApp := app
45+ t.Cleanup(func() { app = oldApp })
46+ cfg := config.DefaultConfig()
47+ cfg.DefaultSearchEngine = "https://search.example/?q=%s"
48+ app = &cli.App{Config: cfg}
49+
50+ if got := parseSelection("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
51+ t.Fatalf("parseSelection(unknown bang) = %q, want search fallback", got)
52+ }
53+}
M internal/cli/cmd/doctor.go +2 -2
@@ -22,7 +22,7 @@ var doctorCmd = &cobra.Command{
22 Long: `Doctor checks system prerequisites for running the GUI browser.22 Long: `Doctor checks system prerequisites for running the GUI browser.
23 23
24 By default it runs both:24 By default it runs both:
25-- Runtime checks (GTK4 + WebKitGTK 6.0)25+- Runtime checks for GTK and WebKit fallback libraries
26 - Media checks (GStreamer/VA-API)26 - Media checks (GStreamer/VA-API)
27 27
28 Use flags to run only one category.28 Use flags to run only one category.
@@ -36,7 +36,7 @@ Examples:
36 36
37 func init() {37 func init() {
38 rootCmd.AddCommand(doctorCmd)38 rootCmd.AddCommand(doctorCmd)
39- doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)")39+ doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks for GTK and WebKit fallback libraries")
40 doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)")40 doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)")
41 }41 }
42 func runDoctor(_ *cobra.Command, _ []string) error {42 func runDoctor(_ *cobra.Command, _ []string) error {
M internal/cli/cmd/root.go +1 -1
@@ -22,7 +22,7 @@ var (
22 SilenceUsage: true,22 SilenceUsage: true,
23 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.23 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
24 24
25-A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.25+A fully unfeatured unbloated browser for tiling WMs, built with GTK4. CEF is the default backend; WebKitGTK is available as a fallback.
26 26
27 Features:27 Features:
28 - Wayland native (Sway, Hyprland, River, Niri, etc.)28 - Wayland native (Sway, Hyprland, River, Niri, etc.)
M internal/cli/cmd/sessions.go +43 -34
@@ -1,6 +1,7 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "fmt"6 "fmt"
6 "os"7 "os"
@@ -39,29 +40,29 @@ func init() {
39 }40 }
40 41
41 func runSessions(_ *cobra.Command, _ []string) error {42 func runSessions(_ *cobra.Command, _ []string) error {
42- app := GetApp()43+ cliApp := GetApp()
43- if app == nil {44+ if cliApp == nil {
44 return fmt.Errorf("app not initialized")45 return fmt.Errorf("app not initialized")
45 }46 }
46 47
47- if app.ListSessionsUC == nil {48+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
48 return fmt.Errorf("session management not available")49 return fmt.Errorf("session management not available")
49 }50 }
50 51
51 // Get current session ID (empty if not running as browser)52 // Get current session ID (empty if not running as browser)
52 var currentSessionID entity.SessionID53 var currentSessionID entity.SessionID
53- if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {54+ if active, err := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {
54 currentSessionID = active.ID55 currentSessionID = active.ID
55 }56 }
56 57
57 // Run interactive TUI58 // Run interactive TUI
58- m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{59+ m := model.NewSessionsModel(cliApp.Ctx(), cliApp.Theme, model.SessionsModelConfig{
59- ListSessionsUC: app.ListSessionsUC,60+ ListSessionsUC: cliApp.ListSessionsUC,
60- RestoreUC: app.RestoreUC,61+ RestoreUC: cliApp.RestoreUC,
61- DeleteSessionUC: app.DeleteSessionUC,62+ DeleteSessionUC: cliApp.DeleteSessionUC,
62- SessionSpawner: app.SessionSpawner,63+ SessionSpawner: cliApp.SessionSpawner,
63 CurrentSession: currentSessionID,64 CurrentSession: currentSessionID,
64- MaxExitedSessions: app.Config.Session.MaxExitedSessions,65+ MaxExitedSessions: cliApp.Config.Session.MaxExitedSessions,
65 })66 })
66 67
67 p := tea.NewProgram(m, tea.WithAltScreen())68 p := tea.NewProgram(m, tea.WithAltScreen())
@@ -89,13 +90,13 @@ func init() {
89 }90 }
90 91
91 func runSessionsList(_ *cobra.Command, _ []string) error {92 func runSessionsList(_ *cobra.Command, _ []string) error {
92- app := GetApp()93+ cliApp := GetApp()
93- if app == nil {94+ if cliApp == nil {
94 return fmt.Errorf("app not initialized")95 return fmt.Errorf("app not initialized")
95 }96 }
96- renderer := styles.NewSessionsCLIRenderer(app.Theme)97+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
97 98
98- if app.ListSessionsUC == nil {99+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
99 err := fmt.Errorf("session management not available")100 err := fmt.Errorf("session management not available")
100 fmt.Fprintln(os.Stderr, renderer.RenderError(err))101 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101 return wrapPrintedError(err)102 return wrapPrintedError(err)
@@ -103,11 +104,11 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
103 104
104 // Get current session ID (empty if not running as browser)105 // Get current session ID (empty if not running as browser)
105 var currentSessionID entity.SessionID106 var currentSessionID entity.SessionID
106- if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {107+ if active, err := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {
107 currentSessionID = active.ID108 currentSessionID = active.ID
108 }109 }
109 110
110- output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)111+ output, err := cliApp.ListSessionsUC.Execute(cliApp.Ctx(), currentSessionID, sessionsLimit)
111 if err != nil {112 if err != nil {
112 wrappedErr := fmt.Errorf("list sessions: %w", err)113 wrappedErr := fmt.Errorf("list sessions: %w", err)
113 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))114 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
@@ -151,13 +152,13 @@ func init() {
151 }152 }
152 153
153 func runSessionsRestore(_ *cobra.Command, args []string) error {154 func runSessionsRestore(_ *cobra.Command, args []string) error {
154- app := GetApp()155+ cliApp := GetApp()
155- if app == nil {156+ if cliApp == nil {
156 return fmt.Errorf("app not initialized")157 return fmt.Errorf("app not initialized")
157 }158 }
158- renderer := styles.NewSessionsCLIRenderer(app.Theme)159+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
159 160
160- if app.RestoreUC == nil || app.ListSessionsUC == nil {161+ if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
161 err := fmt.Errorf("session restoration not available")162 err := fmt.Errorf("session restoration not available")
162 fmt.Fprintln(os.Stderr, renderer.RenderError(err))163 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163 return wrapPrintedError(err)164 return wrapPrintedError(err)
@@ -173,7 +174,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
173 sessionID := sessionInfo.Session.ID174 sessionID := sessionInfo.Session.ID
174 175
175 // Validate the session has restorable state176 // Validate the session has restorable state
176- _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})177+ _, err = cliApp.RestoreUC.Execute(cliApp.Ctx(), usecase.RestoreInput{SessionID: sessionID})
177 if err != nil {178 if err != nil {
178 wrappedErr := fmt.Errorf("restore session: %w", err)179 wrappedErr := fmt.Errorf("restore session: %w", err)
179 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))180 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
@@ -181,12 +182,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
181 }182 }
182 183
183 // Spawn a new dumber instance with the session184 // Spawn a new dumber instance with the session
184- if app.SessionSpawner == nil {185+ if cliApp.SessionSpawner == nil {
185 err := fmt.Errorf("session spawner not available")186 err := fmt.Errorf("session spawner not available")
186 fmt.Fprintln(os.Stderr, renderer.RenderError(err))187 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
187 return wrapPrintedError(err)188 return wrapPrintedError(err)
188 }189 }
189- if err := app.SessionSpawner.SpawnWithSession(sessionID); err != nil {190+ if err := cliApp.SessionSpawner.SpawnWithSession(sessionID); err != nil {
190 wrappedErr := fmt.Errorf("spawn browser: %w", err)191 wrappedErr := fmt.Errorf("spawn browser: %w", err)
191 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))192 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
192 return wrapPrintedError(wrappedErr)193 return wrapPrintedError(wrappedErr)
@@ -219,13 +220,13 @@ func init() {
219 }220 }
220 221
221 func runSessionsDelete(_ *cobra.Command, args []string) error {222 func runSessionsDelete(_ *cobra.Command, args []string) error {
222- app := GetApp()223+ cliApp := GetApp()
223- if app == nil {224+ if cliApp == nil {
224 return fmt.Errorf("app not initialized")225 return fmt.Errorf("app not initialized")
225 }226 }
226- renderer := styles.NewSessionsCLIRenderer(app.Theme)227+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
227 228
228- if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {229+ if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
229 err := fmt.Errorf("session management not available")230 err := fmt.Errorf("session management not available")
230 fmt.Fprintln(os.Stderr, renderer.RenderError(err))231 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
231 return wrapPrintedError(err)232 return wrapPrintedError(err)
@@ -240,12 +241,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
240 241
241 // Get current session ID242 // Get current session ID
242 var currentSessionID entity.SessionID243 var currentSessionID entity.SessionID
243- if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil {244+ if active, activeErr := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); activeErr == nil && active != nil {
244 currentSessionID = active.ID245 currentSessionID = active.ID
245 }246 }
246 247
247 // Delete using use case (handles validation internally)248 // Delete using use case (handles validation internally)
248- if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{249+ if err := cliApp.DeleteSessionUC.Execute(cliApp.Ctx(), usecase.DeleteSessionInput{
249 SessionID: sessionInfo.Session.ID,250 SessionID: sessionInfo.Session.ID,
250 CurrentSessionID: currentSessionID,251 CurrentSessionID: currentSessionID,
251 }); err != nil {252 }); err != nil {
@@ -261,19 +262,27 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
261 // findSessionByIDOrSuffix finds a session by exact ID or unique suffix.262 // findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
262 // Users typically identify sessions by the last few characters (e.g., "dee5").263 // Users typically identify sessions by the last few characters (e.g., "dee5").
263 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {264 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
264- app := GetApp()265+ cliApp := GetApp()
265- if app == nil || app.ListSessionsUC == nil {266+ if cliApp == nil {
266 return nil, fmt.Errorf("app not initialized")267 return nil, fmt.Errorf("app not initialized")
267 }268 }
269+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
270+ return nil, fmt.Errorf("session management not available")
271+ }
272+
273+ ctx := cliApp.Ctx()
274+ if ctx == nil {
275+ ctx = context.Background()
276+ }
268 277
269 // Get current session ID (empty if not running as browser)278 // Get current session ID (empty if not running as browser)
270 var currentSessionID entity.SessionID279 var currentSessionID entity.SessionID
271- if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {280+ if active, err := cliApp.SessionUC.GetActiveSession(ctx); err == nil && active != nil {
272 currentSessionID = active.ID281 currentSessionID = active.ID
273 }282 }
274 283
275- // List all sessions284+ // List all sessions so restore/delete suffix lookup can find older sessions.
276- output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)285+ output, err := cliApp.ListSessionsUC.Execute(ctx, currentSessionID, usecase.ListAllSessionsLimit)
277 if err != nil {286 if err != nil {
278 return nil, fmt.Errorf("list sessions: %w", err)287 return nil, fmt.Errorf("list sessions: %w", err)
279 }288 }
A internal/cli/cmd/sessions_test.go +196 -0
@@ -0,0 +1,196 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli"
13+ "github.com/bnema/dumber/internal/cli/styles"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestSessionCommandsReturnManagementErrorWhenSessionUseCaseMissing(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ run func() error
23+ }{
24+ {
25+ name: "interactive sessions",
26+ run: func() error { return runSessions(nil, nil) },
27+ },
28+ {
29+ name: "list sessions",
30+ run: func() error { return runSessionsList(nil, nil) },
31+ },
32+ {
33+ name: "delete session",
34+ run: func() error { return runSessionsDelete(nil, []string{"session-id"}) },
35+ },
36+ {
37+ name: "find session helper",
38+ run: func() error {
39+ _, err := findSessionByIDOrSuffix("session-id")
40+ return err
41+ },
42+ },
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ withApp(t, sessionCommandTestApp())
48+ discardStderr(t)
49+
50+ err := tt.run()
51+
52+ require.ErrorContains(t, err, "session management not available")
53+ })
54+ }
55+}
56+
57+func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testing.T) {
58+ withApp(t, sessionCommandTestApp())
59+ discardStderr(t)
60+
61+ err := runSessionsRestore(nil, []string{"session-id"})
62+
63+ require.ErrorContains(t, err, "session restoration not available")
64+}
65+
66+func TestFindSessionByIDOrSuffixSearchesBeyondDefaultLimit(t *testing.T) {
67+ targetID := entity.SessionID("20251224_000000_targetolder")
68+ sessions := make([]*entity.Session, 0, defaultSessionsLimit*5+1)
69+ startedAt := time.Date(2025, 12, 24, 12, 0, 0, 0, time.UTC)
70+ for i := 0; i < defaultSessionsLimit*5; i++ {
71+ sessions = append(sessions, &entity.Session{
72+ ID: entity.SessionID(fmt.Sprintf("20251224_1200%02d_recent", i)),
73+ Type: entity.SessionTypeBrowser,
74+ StartedAt: startedAt.Add(-time.Duration(i) * time.Minute),
75+ })
76+ }
77+ sessions = append(sessions, &entity.Session{
78+ ID: targetID,
79+ Type: entity.SessionTypeBrowser,
80+ StartedAt: startedAt.Add(-time.Hour),
81+ })
82+
83+ sessionRepo := &sessionCommandSessionRepo{sessions: sessions}
84+ cfg := config.DefaultConfig()
85+ withApp(t, &cli.App{
86+ Config: cfg,
87+ Theme: styles.NewTheme(cfg),
88+ SessionUC: usecase.NewManageSessionUseCase(sessionRepo, nil),
89+ ListSessionsUC: usecase.NewListSessionsUseCase(sessionRepo, sessionCommandStateRepo{}),
90+ })
91+
92+ got, err := findSessionByIDOrSuffix("targetolder")
93+
94+ require.NoError(t, err)
95+ require.Equal(t, targetID, got.Session.ID)
96+}
97+
98+func sessionCommandTestApp() *cli.App {
99+ cfg := config.DefaultConfig()
100+ return &cli.App{
101+ Config: cfg,
102+ Theme: styles.NewTheme(cfg),
103+ ListSessionsUC: &usecase.ListSessionsUseCase{},
104+ RestoreUC: &usecase.RestoreSessionUseCase{},
105+ DeleteSessionUC: &usecase.DeleteSessionUseCase{},
106+ }
107+}
108+
109+func withApp(t *testing.T, testApp *cli.App) {
110+ t.Helper()
111+ oldApp := app
112+ app = testApp
113+ t.Cleanup(func() { app = oldApp })
114+}
115+
116+func discardStderr(t *testing.T) {
117+ t.Helper()
118+ oldStderr := os.Stderr
119+ reader, writer, err := os.Pipe()
120+ require.NoError(t, err)
121+ os.Stderr = writer
122+ t.Cleanup(func() {
123+ os.Stderr = oldStderr
124+ require.NoError(t, writer.Close())
125+ _, _ = io.Copy(io.Discard, reader)
126+ require.NoError(t, reader.Close())
127+ })
128+}
129+
130+type sessionCommandSessionRepo struct {
131+ sessions []*entity.Session
132+}
133+
134+func (*sessionCommandSessionRepo) Save(context.Context, *entity.Session) error {
135+ return nil
136+}
137+
138+func (r *sessionCommandSessionRepo) FindByID(_ context.Context, id entity.SessionID) (*entity.Session, error) {
139+ for _, session := range r.sessions {
140+ if session.ID == id {
141+ return session, nil
142+ }
143+ }
144+ return nil, nil
145+}
146+
147+func (*sessionCommandSessionRepo) GetActive(context.Context) (*entity.Session, error) {
148+ return nil, nil
149+}
150+
151+func (r *sessionCommandSessionRepo) GetRecent(_ context.Context, limit int) ([]*entity.Session, error) {
152+ if limit < 0 || limit > len(r.sessions) {
153+ limit = len(r.sessions)
154+ }
155+ out := make([]*entity.Session, limit)
156+ copy(out, r.sessions[:limit])
157+ return out, nil
158+}
159+
160+func (*sessionCommandSessionRepo) MarkEnded(context.Context, entity.SessionID, time.Time) error {
161+ return nil
162+}
163+
164+func (*sessionCommandSessionRepo) Delete(context.Context, entity.SessionID) error {
165+ return nil
166+}
167+
168+func (*sessionCommandSessionRepo) DeleteOldestExited(context.Context, int) (int64, error) {
169+ return 0, nil
170+}
171+
172+func (*sessionCommandSessionRepo) DeleteExitedBefore(context.Context, time.Time) (int64, error) {
173+ return 0, nil
174+}
175+
176+type sessionCommandStateRepo struct{}
177+
178+func (sessionCommandStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error {
179+ return nil
180+}
181+
182+func (sessionCommandStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) {
183+ return nil, nil
184+}
185+
186+func (sessionCommandStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error {
187+ return nil
188+}
189+
190+func (sessionCommandStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) {
191+ return nil, nil
192+}
193+
194+func (sessionCommandStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) {
195+ return 0, nil
196+}
M internal/cli/cmd/setup.go +8 -8
@@ -57,12 +57,12 @@ func init() {
57 }57 }
58 58
59 func runSetupInstall(_ *cobra.Command, _ []string) error {59 func runSetupInstall(_ *cobra.Command, _ []string) error {
60- app := GetApp()60+ cliApp := GetApp()
61- if app == nil {61+ if cliApp == nil {
62 return fmt.Errorf("app not initialized")62 return fmt.Errorf("app not initialized")
63 }63 }
64 64
65- theme := app.Theme65+ theme := cliApp.Theme
66 66
67 // Check if installed via package manager67 // Check if installed via package manager
68 switch {68 switch {
@@ -87,7 +87,7 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
87 IconData: assets.LogoSVG,87 IconData: assets.LogoSVG,
88 }88 }
89 89
90- result, err := uc.Execute(app.Ctx(), input)90+ result, err := uc.Execute(cliApp.Ctx(), input)
91 if err != nil {91 if err != nil {
92 fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), err.Error())92 fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), err.Error())
93 return err93 return err
@@ -124,16 +124,16 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
124 }124 }
125 125
126 func runSetupDefault(_ *cobra.Command, _ []string) error {126 func runSetupDefault(_ *cobra.Command, _ []string) error {
127- app := GetApp()127+ cliApp := GetApp()
128- if app == nil {128+ if cliApp == nil {
129 return fmt.Errorf("app not initialized")129 return fmt.Errorf("app not initialized")
130 }130 }
131 131
132- theme := app.Theme132+ theme := cliApp.Theme
133 adapter := desktop.New()133 adapter := desktop.New()
134 uc := usecase.NewSetDefaultBrowserUseCase(adapter)134 uc := usecase.NewSetDefaultBrowserUseCase(adapter)
135 135
136- result, err := uc.Execute(app.Ctx())136+ result, err := uc.Execute(cliApp.Ctx())
137 if err != nil {137 if err != nil {
138 errMsg := err.Error()138 errMsg := err.Error()
139 fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), errMsg)139 fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), errMsg)
M internal/cli/cmd/update.go +7 -7
@@ -203,12 +203,12 @@ func (updateModel) checkForUpdates() tea.Cmd {
203 return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}203 return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
204 }204 }
205 205
206- app := GetApp()206+ cliApp := GetApp()
207- if app == nil {207+ if cliApp == nil {
208 return checkResultMsg{err: fmt.Errorf("app not initialized")}208 return checkResultMsg{err: fmt.Errorf("app not initialized")}
209 }209 }
210 210
211- checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)211+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, cliApp.BuildInfo)
212 result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})212 result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213 213
214 return checkResultMsg{output: result, err: err}214 return checkResultMsg{output: result, err: err}
@@ -240,15 +240,15 @@ func (m updateModel) downloadUpdate() tea.Cmd {
240 }240 }
241 241
242 func runUpdate(_ *cobra.Command, _ []string) error {242 func runUpdate(_ *cobra.Command, _ []string) error {
243- app := GetApp()243+ cliApp := GetApp()
244- if app == nil {244+ if cliApp == nil {
245 return fmt.Errorf("app not initialized")245 return fmt.Errorf("app not initialized")
246 }246 }
247 247
248- renderer := styles.NewUpdateRenderer(app.Theme)248+ renderer := styles.NewUpdateRenderer(cliApp.Theme)
249 249
250 // Create and run the bubbletea program.250 // Create and run the bubbletea program.
251- m := newUpdateModel(renderer, app.Theme.Accent, updateForce)251+ m := newUpdateModel(renderer, cliApp.Theme.Accent, updateForce)
252 p := tea.NewProgram(m)252 p := tea.NewProgram(m)
253 253
254 finalModel, err := p.Run()254 finalModel, err := p.Run()
M internal/domain/url/normalize.go +2 -32
@@ -7,21 +7,9 @@ import (
7 "fmt"7 "fmt"
8 "net"8 "net"
9 "net/url"9 "net/url"
10- "os"
11- "path/filepath"
12 "strings"10 "strings"
13 )11 )
14 12
15-// expandHome expands ~ prefix to user's home directory.
16-func expandHome(path string) string {
17- if strings.HasPrefix(path, "~/") {
18- if home, err := os.UserHomeDir(); err == nil {
19- return filepath.Join(home, path[2:])
20- }
21- }
22- return path
23-}
24-
25 // looksLikeFilePath returns true if the input looks like a filesystem path.13 // looksLikeFilePath returns true if the input looks like a filesystem path.
26 // This includes absolute paths (/path), relative paths (./path, ../path),14 // This includes absolute paths (/path), relative paths (./path, ../path),
27 // and home-relative paths (~/path).15 // and home-relative paths (~/path).
@@ -41,7 +29,7 @@ func looksLikeFilePath(input string) bool {
41 29
42 // Normalize adds https:// prefix if missing for URL-like inputs.30 // Normalize adds https:// prefix if missing for URL-like inputs.
43 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.31 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
44-// If the input is an existing local file, returns a file:// URL.32+// Path-like inputs are returned unchanged; filesystem probing belongs in application adapters.
45 func Normalize(input string) string {33 func Normalize(input string) string {
46 if input == "" {34 if input == "" {
47 return ""35 return ""
@@ -65,29 +53,11 @@ func Normalize(input string) string {
65 return input53 return input
66 }54 }
67 55
68- // Check if input looks like a file path56+ // Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
69 if looksLikeFilePath(input) {57 if looksLikeFilePath(input) {
70- expanded := expandHome(input)
71- absPath, err := filepath.Abs(expanded)
72- if err == nil {
73- if _, statErr := os.Stat(absPath); statErr == nil {
74- // File exists, convert to file:// URL
75- return "file://" + absPath
76- }
77- }
78- // Path-like input but file doesn't exist - return unchanged
79- // Don't treat as URL to avoid malformed URLs like https:///path
80 return input58 return input
81 }59 }
82 60
83- // Check if input is an existing file in current directory (relative without ./ prefix)
84- absPath, err := filepath.Abs(input)
85- if err == nil {
86- if _, statErr := os.Stat(absPath); statErr == nil {
87- return "file://" + absPath
88- }
89- }
90-
91 // Looks like a URL (contains . and no spaces)61 // Looks like a URL (contains . and no spaces)
92 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {62 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
93 return "http://" + input63 return "http://" + input
M internal/domain/url/normalize_test.go +7 -116
@@ -1,8 +1,6 @@
1 package url1 package url
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6 "strings"4 "strings"
7 "testing"5 "testing"
8 )6 )
@@ -120,128 +118,21 @@ func TestNormalize(t *testing.T) {
120 }118 }
121 }119 }
122 120
123-func TestNormalize_LocalFiles(t *testing.T) {121+func TestNormalize_PathLikeInputsArePure(t *testing.T) {
124- // Create a temporary file for testing
125- tmpDir := t.TempDir()
126- tmpFile := filepath.Join(tmpDir, "test.html")
127- if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
128- t.Fatalf("failed to create temp file: %v", err)
129- }
130-
131- // Create a subdirectory with a file
132- subDir := filepath.Join(tmpDir, "subdir")
133- if err := os.Mkdir(subDir, 0755); err != nil {
134- t.Fatalf("failed to create subdir: %v", err)
135- }
136- subFile := filepath.Join(subDir, "page.html")
137- if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
138- t.Fatalf("failed to create subdir file: %v", err)
139- }
140-
141- tests := []struct {
142- name string
143- input string
144- want string
145- }{
146- {
147- name: "absolute path to existing file",
148- input: tmpFile,
149- want: "file://" + tmpFile,
150- },
151- {
152- name: "absolute path to existing directory",
153- input: tmpDir,
154- want: "file://" + tmpDir,
155- },
156- {
157- name: "non-existent absolute path returned unchanged",
158- input: "/nonexistent/file.html",
159- want: "/nonexistent/file.html",
160- },
161- {
162- name: "non-existent relative path with dot prefix returned unchanged",
163- input: "./nonexistent/file.html",
164- want: "./nonexistent/file.html",
165- },
166- {
167- name: "non-existent home path returned unchanged",
168- input: "~/nonexistent/file.html",
169- want: "~/nonexistent/file.html",
170- },
171- }
172-
173- for _, tt := range tests {
174- t.Run(tt.name, func(t *testing.T) {
175- got := Normalize(tt.input)
176- if got != tt.want {
177- t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
178- }
179- })
180- }
181-}
182-
183-func TestNormalize_RelativePaths(t *testing.T) {
184- // Create a temp file in current directory
185- tmpFile, err := os.CreateTemp(".", "test-*.html")
186- if err != nil {
187- t.Fatalf("failed to create temp file: %v", err)
188- }
189- tmpFile.Close()
190- defer os.Remove(tmpFile.Name())
191-
192- // Get absolute path for comparison
193- absPath, err := filepath.Abs(tmpFile.Name())
194- if err != nil {
195- t.Fatalf("failed to get abs path: %v", err)
196- }
197-
198- t.Run("relative path to existing file", func(t *testing.T) {
199- got := Normalize(tmpFile.Name())
200- want := "file://" + absPath
201- if got != want {
202- t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
203- }
204- })
205-}
206-
207-func TestExpandHome(t *testing.T) {
208- home, err := os.UserHomeDir()
209- if err != nil {
210- t.Skip("cannot get home directory")
211- }
212-
213 tests := []struct {122 tests := []struct {
214 name string123 name string
215 input string124 input string
216- want string
217 }{125 }{
218- {126+ {name: "absolute path", input: "/tmp/example.html"},
219- name: "tilde expansion",127+ {name: "relative path with dot", input: "./example.html"},
220- input: "~/Documents",128+ {name: "relative parent path", input: "../example.html"},
221- want: filepath.Join(home, "Documents"),129+ {name: "home path", input: "~/example.html"},
222- },
223- {
224- name: "no tilde unchanged",
225- input: "/absolute/path",
226- want: "/absolute/path",
227- },
228- {
229- name: "relative path unchanged",
230- input: "./relative/path",
231- want: "./relative/path",
232- },
233- {
234- name: "tilde in middle unchanged",
235- input: "/path/~/file",
236- want: "/path/~/file",
237- },
238 }130 }
239 131
240 for _, tt := range tests {132 for _, tt := range tests {
241 t.Run(tt.name, func(t *testing.T) {133 t.Run(tt.name, func(t *testing.T) {
242- got := expandHome(tt.input)134+ if got := Normalize(tt.input); got != tt.input {
243- if got != tt.want {135+ t.Errorf("Normalize(%q) = %q, want unchanged", tt.input, got)
244- t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
245 }136 }
246 })137 })
247 }138 }
M internal/infrastructure/cef/engine_init_test.go +19 -2
@@ -181,12 +181,18 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
181 func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {181 func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
182 t.Helper()182 t.Helper()
183 cwd := t.TempDir()183 cwd := t.TempDir()
184+ runtimeDir := shortRuntimeDir(t)
185+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
184 profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{186 profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
185 Env: func(key string) string {187 Env: func(key string) string {
186- if key == "ENV" {188+ switch key {
189+ case "ENV":
187 return "dev"190 return "dev"
191+ case "XDG_RUNTIME_DIR":
192+ return runtimeDir
193+ default:
194+ return ""
188 }195 }
189- return ""
190 },196 },
191 Engine: "cef",197 Engine: "cef",
192 CWD: func() (string, error) { return cwd, nil },198 CWD: func() (string, error) { return cwd, nil },
@@ -195,6 +201,17 @@ func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
195 return profile201 return profile
196 }202 }
197 203
204+func shortRuntimeDir(t *testing.T) string {
205+ t.Helper()
206+
207+ dir, err := os.MkdirTemp("", "dbr-")
208+ require.NoError(t, err)
209+ t.Cleanup(func() {
210+ _ = os.RemoveAll(dir)
211+ })
212+ return dir
213+}
214+
198 func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {215 func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {
199 t.Helper()216 t.Helper()
200 root := t.TempDir()217 root := t.TempDir()
M internal/infrastructure/cef/scale_probe.go +2 -2
@@ -41,10 +41,10 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
41 }41 }
42 m.UserZoom = normalizeScale(wv.GetZoomLevel())42 m.UserZoom = normalizeScale(wv.GetZoomLevel())
43 m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())43 m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
44- m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale44+ m.SurfaceScale = normalizeScale(wv.viewBridgeScale())
45+ m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale)
45 if wv.viewBridge != nil {46 if wv.viewBridge != nil {
46 m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()47 m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
47- m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
48 }48 }
49 return m49 return m
50 }50 }
M internal/infrastructure/cef/webview.go +37 -23
@@ -214,12 +214,12 @@ type WebView struct {
214 loadDiagLastLoadStateAt time.Time214 loadDiagLastLoadStateAt time.Time
215 215
216 // Atomic state.216 // Atomic state.
217- destroyed atomic.Bool217+ destroyed atomic.Bool
218- fullscreen atomic.Bool218+ fullscreen atomic.Bool
219- generation atomic.Uint64219+ generation atomic.Uint64
220- audioPlaying atomic.Bool220+ audioPlaying atomic.Bool
221- zoomFactor atomic.Value // float64, initialized to 1.0221+ zoomFactor atomic.Value // float64, initialized to 1.0
222- lastAppliedOSRBackingScaleBits atomic.Uint64222+ lastAppliedZoomScaleRatioBits atomic.Uint64
223 223
224 // Browser creation defaults copied from the factory so native popup shells224 // Browser creation defaults copied from the factory so native popup shells
225 // can apply the same settings in OnBeforePopup.225 // can apply the same settings in OnBeforePopup.
@@ -491,15 +491,23 @@ func cefZoomFromFactor(factor float64) float64 {
491 return math.Log(factor) / math.Log(chromiumZoomBase)491 return math.Log(factor) / math.Log(chromiumZoomBase)
492 }492 }
493 493
494-func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {494+func zoomScaleRatio(surfaceScale, backingScale float64) float64 {
495- return cefZoomFromFactor(pageZoom * normalizeScale(backingScale))495+ backingScale = normalizeScale(backingScale)
496+ if backingScale <= 1 {
497+ return 1
498+ }
499+ return normalizeScale(surfaceScale) / backingScale
500+}
501+
502+func cefZoomFromPageAndScaleFactors(pageZoom, surfaceScale, backingScale float64) float64 {
503+ return cefZoomFromFactor(pageZoom * zoomScaleRatio(surfaceScale, backingScale))
496 }504 }
497 505
498-func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {506+func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 {
499- return factorFromCEFZoom(level) / normalizeScale(backingScale)507+ return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale)
500 }508 }
501 509
502-func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {510+func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, surfaceScale, backingScale float64) {
503 host.SetZoomLevel(cefLevel)511 host.SetZoomLevel(cefLevel)
504 // Force CEF to produce a new frame at the new zoom level. In OSR mode,512 // Force CEF to produce a new frame at the new zoom level. In OSR mode,
505 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a513 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
@@ -508,7 +516,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel,
508 // SynchronizeVisualProperties cycle, which makes the renderer produce516 // SynchronizeVisualProperties cycle, which makes the renderer produce
509 // a new compositor frame at the new zoom level.517 // a new compositor frame at the new zoom level.
510 host.NotifyScreenInfoChanged()518 host.NotifyScreenInfoChanged()
511- wv.recordAppliedOSRBackingScale(backingScale)519+ wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale)
512 // Zoom is applied asynchronously in the renderer process. Request a couple520 // Zoom is applied asynchronously in the renderer process. Request a couple
513 // of follow-up refreshes on the CEF UI thread so OSR captures the updated521 // of follow-up refreshes on the CEF UI thread so OSR captures the updated
514 // compositor frame after the zoom IPC has been processed.522 // compositor frame after the zoom IPC has been processed.
@@ -541,14 +549,16 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
541 if host == nil {549 if host == nil {
542 return errNoBrowser550 return errNoBrowser
543 }551 }
552+ surfaceScale := wv.viewBridgeScale()
544 backingScale := wv.osrBackingScaleFactor()553 backingScale := wv.osrBackingScaleFactor()
545- cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)554+ cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
546 logging.FromContext(wv.ctx).Debug().555 logging.FromContext(wv.ctx).Debug().
547 Float64("factor", factor).556 Float64("factor", factor).
548 Float64("cef_level", cefLevel).557 Float64("cef_level", cefLevel).
549 Float64("osr_backing_scale", backingScale).558 Float64("osr_backing_scale", backingScale).
559+ Float64("surface_scale", surfaceScale).
550 Msg("cef: SetZoomLevel")560 Msg("cef: SetZoomLevel")
551- wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)561+ wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
552 wv.zoomFactor.Store(factor)562 wv.zoomFactor.Store(factor)
553 return nil563 return nil
554 }564 }
@@ -1665,23 +1675,23 @@ func (wv *WebView) osrBackingScaleFactor() float64 {
1665 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())1675 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
1666 }1676 }
1667 1677
1668-func (wv *WebView) recordAppliedOSRBackingScale(scale float64) {1678+func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) {
1669 if wv == nil {1679 if wv == nil {
1670 return1680 return
1671 }1681 }
1672- wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale)))1682+ wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale)))
1673 }1683 }
1674 1684
1675-func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool {1685+func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool {
1676 if wv == nil {1686 if wv == nil {
1677 return false1687 return false
1678 }1688 }
1679- normalized := normalizeScale(scale)1689+ ratio := zoomScaleRatio(surfaceScale, backingScale)
1680- bits := wv.lastAppliedOSRBackingScaleBits.Load()1690+ bits := wv.lastAppliedZoomScaleRatioBits.Load()
1681 if bits == 0 {1691 if bits == 0 {
1682 return true1692 return true
1683 }1693 }
1684- return math.Float64frombits(bits) != normalized1694+ return math.Float64frombits(bits) != ratio
1685 }1695 }
1686 1696
1687 func (wv *WebView) handleMiddleClickFromBridge() bool {1697 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -1982,16 +1992,18 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
1982 if host == nil {1992 if host == nil {
1983 return1993 return
1984 }1994 }
1995+ surfaceScale := wv.viewBridgeScale()
1985 backingScale := wv.osrBackingScaleFactor()1996 backingScale := wv.osrBackingScaleFactor()
1986 actualLevel := host.GetZoomLevel()1997 actualLevel := host.GetZoomLevel()
1987 logging.FromContext(wv.ctx).Debug().1998 logging.FromContext(wv.ctx).Debug().
1988 Int64("delay_ms", delayMs).1999 Int64("delay_ms", delayMs).
1989 Float64("expected_factor", expectedFactor).2000 Float64("expected_factor", expectedFactor).
1990 Float64("expected_cef_level", expectedLevel).2001 Float64("expected_cef_level", expectedLevel).
1991- Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)).2002+ Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)).
1992 Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).2003 Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
1993 Float64("actual_cef_level", actualLevel).2004 Float64("actual_cef_level", actualLevel).
1994 Float64("osr_backing_scale", backingScale).2005 Float64("osr_backing_scale", backingScale).
2006+ Float64("surface_scale", surfaceScale).
1995 Msg("cef: zoom level readback")2007 Msg("cef: zoom level readback")
1996 }))2008 }))
1997 if task == nil {2009 if task == nil {
@@ -2012,14 +2024,16 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
2012 return2024 return
2013 }2025 }
2014 factor := wv.GetZoomLevel()2026 factor := wv.GetZoomLevel()
2027+ surfaceScale := wv.viewBridgeScale()
2015 backingScale := wv.osrBackingScaleFactor()2028 backingScale := wv.osrBackingScaleFactor()
2016- cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)2029+ cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
2017- wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)2030+ wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
2018 logging.FromContext(wv.ctx).Debug().2031 logging.FromContext(wv.ctx).Debug().
2019 Str("reason", reason).2032 Str("reason", reason).
2020 Float64("factor", factor).2033 Float64("factor", factor).
2021 Float64("cef_level", cefLevel).2034 Float64("cef_level", cefLevel).
2022 Float64("osr_backing_scale", backingScale).2035 Float64("osr_backing_scale", backingScale).
2036+ Float64("surface_scale", surfaceScale).
2023 Msg("cef: reapplied zoom after OSR backing scale change")2037 Msg("cef: reapplied zoom after OSR backing scale change")
2024 }2038 }
2025 2039
M internal/infrastructure/cef/webview_handlers.go +2 -2
@@ -783,7 +783,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
783 783
784 h.wv.browser = browser784 h.wv.browser = browser
785 h.wv.host = host785 h.wv.host = host
786- h.wv.lastAppliedOSRBackingScaleBits.Store(0)786+ h.wv.lastAppliedZoomScaleRatioBits.Store(0)
787 h.wv.pendingCreate = nil787 h.wv.pendingCreate = nil
788 bridge := h.wv.viewBridge788 bridge := h.wv.viewBridge
789 h.wv.inputAttached = bridge == nil789 h.wv.inputAttached = bridge == nil
@@ -901,7 +901,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
901 h.wv.mu.Lock()901 h.wv.mu.Lock()
902 h.wv.browser = nil902 h.wv.browser = nil
903 h.wv.host = nil903 h.wv.host = nil
904- h.wv.lastAppliedOSRBackingScaleBits.Store(0)904+ h.wv.lastAppliedZoomScaleRatioBits.Store(0)
905 h.wv.inputAttached = false905 h.wv.inputAttached = false
906 if h.wv.findCtrl != nil {906 if h.wv.findCtrl != nil {
907 h.wv.findCtrl.setHost(nil)907 h.wv.findCtrl.setHost(nil)
M internal/infrastructure/cef/webview_viewport.go +3 -2
@@ -500,7 +500,7 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
500 if host == nil || wv == nil {500 if host == nil || wv == nil {
501 return501 return
502 }502 }
503- if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) {503+ if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) {
504 return504 return
505 }505 }
506 task := cefNewTask(cefTaskFunc(func() {506 task := cefNewTask(cefTaskFunc(func() {
@@ -513,8 +513,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
513 if currentHost != host {513 if currentHost != host {
514 return514 return
515 }515 }
516+ surfaceScale := wv.viewBridgeScale()
516 backingScale := wv.osrBackingScaleFactor()517 backingScale := wv.osrBackingScaleFactor()
517- if !wv.shouldReapplyZoomForBackingScale(backingScale) {518+ if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) {
518 return519 return
519 }520 }
520 wv.reapplyCurrentZoomForBackingScale(reason)521 wv.reapplyCurrentZoomForBackingScale(reason)
M internal/infrastructure/cef/webview_zoom_test.go +53 -9
@@ -18,14 +18,58 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
18 }18 }
19 }19 }
20 20
21-func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) {21+func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) {
22- for _, backing := range []float64{1.0, 1.25, 1.44, 2.0} {22+ tests := []struct {
23- for _, pageZoom := range []float64{1.0, 1.75} {23+ name string
24- t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) {24+ surfaceScale float64
25- level := cefZoomFromPageAndBackingFactor(pageZoom, backing)25+ backingScale float64
26- assert.InDelta(t, pageZoom*backing, factorFromCEFZoom(level), 1e-9)26+ pageZoom float64
27- assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9)27+ wantInternal float64
28- })28+ }{
29- }29+ {
30+ name: "fractional_hidpi_keeps_100_percent_internal_zoom",
31+ surfaceScale: 1.2,
32+ backingScale: 1.2,
33+ pageZoom: 1.0,
34+ wantInternal: 1.0,
35+ },
36+ {
37+ name: "integer_hidpi_keeps_requested_user_zoom",
38+ surfaceScale: 2.0,
39+ backingScale: 2.0,
40+ pageZoom: 1.2,
41+ wantInternal: 1.2,
42+ },
43+ {
44+ name: "normal_osr_hidpi_keeps_requested_user_zoom",
45+ surfaceScale: 1.2,
46+ backingScale: 1.0,
47+ pageZoom: 1.0,
48+ wantInternal: 1.0,
49+ },
50+ {
51+ name: "device_sized_backing_remains_compensated",
52+ surfaceScale: 1.25,
53+ backingScale: 2.0,
54+ pageZoom: 1.2,
55+ wantInternal: 0.75,
56+ },
30 }57 }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ level := cefZoomFromPageAndScaleFactors(tt.pageZoom, tt.surfaceScale, tt.backingScale)
62+ assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9)
63+ assert.InDelta(t, tt.pageZoom, pageZoomFromCEFAndScaleLevel(level, tt.surfaceScale, tt.backingScale), 1e-9)
64+ })
65+ }
66+}
67+
68+func TestZoomReapplyGuardTracksEffectiveScaleRatio(t *testing.T) {
69+ wv := &WebView{}
70+
71+ wv.recordAppliedZoomScaleRatio(1.2, 1.2)
72+
73+ assert.False(t, wv.shouldReapplyZoomForScaleRatio(2.0, 2.0), "same effective ratio should not reapply")
74+ assert.True(t, wv.shouldReapplyZoomForScaleRatio(1.2, 2.0), "changed active-backing ratio should reapply")
31 }75 }
M internal/infrastructure/config/loader.go +31 -11
@@ -311,27 +311,47 @@ func bindBrowsingContextEnvAliases(v *viper.Viper) error {
311 311
312 // checkLegacyFormat detects old config format and returns an error directing user to migrate.312 // checkLegacyFormat detects old config format and returns an error directing user to migrate.
313 func (m *Manager) checkLegacyFormat() error {313 func (m *Manager) checkLegacyFormat() error {
314- // Check if old sections exist by looking for known keys.
315 // IsSet works for legacy keys because they do not have defaults, while314 // IsSet works for legacy keys because they do not have defaults, while
316 // engine.type has a default and must be checked with InConfig.315 // engine.type has a default and must be checked with InConfig.
317- hasOldSections := m.viper.IsSet("rendering.mode") ||316+ hasOldSections := false
318- m.viper.IsSet("rendering.disable_dmabuf_renderer") ||317+ for _, alias := range legacyEngineAliases {
319- m.viper.IsSet("performance.profile") ||318+ if m.viper.IsSet(alias.legacyKey()) {
320- m.viper.IsSet("privacy.cookie_policy") ||319+ hasOldSections = true
321- m.viper.IsSet("runtime.prefix")320+ break
321+ }
322+ }
322 323
323- hasEngineSection := m.viper.InConfig("engine.type")324+ hasEngineSection := m.hasEngineSectionInConfig()
324 325
325- if hasOldSections && !hasEngineSection {326+ if hasOldSections && hasEngineSection {
327+ return fmt.Errorf(
328+ "mixed old/new config: [engine] coexists with legacy engine keys in %s; "+
329+ "remove legacy keys or migrate from a purely legacy config",
330+ legacyEngineInputSectionsMessage(),
331+ )
332+ }
333+ if hasOldSections {
326 return fmt.Errorf(334 return fmt.Errorf(
327- "config format outdated: [rendering], [performance], [privacy] sections " +335+ "config format outdated: legacy engine keys in %s have moved to "+
328- "have moved to [engine]/[engine.webkit] — " +336+ "[engine]/[engine.webkit] — run \"dumber config migrate\" "+
329- "run \"dumber config migrate\" to update your config file",337+ "to update your config file",
338+ legacyEngineInputSectionsMessage(),
330 )339 )
331 }340 }
332 return nil341 return nil
333 }342 }
334 343
344+func (m *Manager) hasEngineSectionInConfig() bool {
345+ if configFile := m.viper.ConfigFileUsed(); configFile != "" {
346+ raw, err := readRawTOML(configFile)
347+ if err == nil {
348+ _, exists := raw["engine"]
349+ return exists
350+ }
351+ }
352+ return m.viper.InConfig("engine.type")
353+}
354+
335 func (m *Manager) unmarshalConfig() (*Config, error) {355 func (m *Manager) unmarshalConfig() (*Config, error) {
336 config := &Config{}356 config := &Config{}
337 if err := m.viper.Unmarshal(config); err != nil {357 if err := m.viper.Unmarshal(config); err != nil {
M internal/infrastructure/config/loader_legacy_test.go +60 -0
@@ -29,6 +29,22 @@ func TestCheckLegacyFormat_OldSectionsWithEngine_ReturnsError(t *testing.T) {
29 require.Error(t, err)29 require.Error(t, err)
30 require.Contains(t, err.Error(), "dumber config migrate")30 require.Contains(t, err.Error(), "dumber config migrate")
31 }31 }
32+func TestCheckLegacyFormat_MixedEngineSectionReturnsMixedConfigError(t *testing.T) {
33+ dir := t.TempDir()
34+ configPath := filepath.Join(dir, "config.toml")
35+ require.NoError(t, os.WriteFile(configPath, []byte("[engine]\nprofile = \"balanced\"\n\n[rendering]\ngsk_renderer = \"auto\"\n"), 0o600))
36+
37+ m := &Manager{viper: viper.New()}
38+ m.viper.SetConfigFile(configPath)
39+ m.viper.SetConfigType("toml")
40+ require.NoError(t, m.viper.ReadInConfig())
41+
42+ err := m.checkLegacyFormat()
43+
44+ require.Error(t, err)
45+ require.Contains(t, err.Error(), "mixed old/new config")
46+ require.NotContains(t, err.Error(), "dumber config migrate")
47+}
32 48
33 func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) {49 func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) {
34 m := &Manager{viper: viper.New()}50 m := &Manager{viper: viper.New()}
@@ -78,6 +94,50 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) {
78 require.Contains(t, err.Error(), "dumber config migrate")94 require.Contains(t, err.Error(), "dumber config migrate")
79 }95 }
80 96
97+func TestCheckLegacyFormat_MetadataBackedAliasesReturnError(t *testing.T) {
98+ for _, alias := range legacyEngineAliases {
99+ key := alias.legacyKey()
100+ t.Run(key, func(t *testing.T) {
101+ m := &Manager{viper: viper.New()}
102+ m.viper.Set(key, sampleLegacyAliasValue(alias))
103+
104+ err := m.checkLegacyFormat()
105+
106+ require.Error(t, err)
107+ require.Contains(t, err.Error(), "dumber config migrate")
108+ })
109+ }
110+}
111+
112+func sampleLegacyAliasValue(alias legacyEngineAlias) any {
113+ switch alias.legacyKey() {
114+ case "performance.profile":
115+ return "balanced"
116+ case "privacy.cookie_policy":
117+ return "always"
118+ case "rendering.mode":
119+ return "gpu"
120+ case "rendering.gsk_renderer", "media.gl_rendering_mode":
121+ return "auto"
122+ case "runtime.prefix":
123+ return "/usr"
124+ }
125+
126+ switch alias.legacyField {
127+ case "zoom_cache_size", "webview_pool_prewarm_count",
128+ "skia_cpu_painting_threads", "skia_gpu_painting_threads",
129+ "web_process_memory_limit_mb", "network_process_memory_limit_mb",
130+ "gstreamer_debug_level":
131+ return 1
132+ case "web_process_memory_poll_interval_sec", "network_process_memory_poll_interval_sec",
133+ "web_process_memory_conservative_threshold", "web_process_memory_strict_threshold",
134+ "network_process_memory_conservative_threshold", "network_process_memory_strict_threshold":
135+ return 0.5
136+ default:
137+ return true
138+ }
139+}
140+
81 func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) {141 func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) {
82 dir := t.TempDir()142 dir := t.TempDir()
83 configPath := filepath.Join(dir, "config.toml")143 configPath := filepath.Join(dir, "config.toml")
M internal/infrastructure/config/migrate_engine.go +169 -89
@@ -2,87 +2,168 @@ package config
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "github.com/pelletier/go-toml/v2"
6 "io/fs"5 "io/fs"
7 "os"6 "os"
7+ "strings"
8+
9+ "github.com/pelletier/go-toml/v2"
8 )10 )
9 11
10 // configFilePermissions is the file permission used when writing migrated config files.12 // configFilePermissions is the file permission used when writing migrated config files.
11 const configFilePermissions fs.FileMode = 0o64413 const configFilePermissions fs.FileMode = 0o644
12 14
13-// engineMigrationMapping defines old key -> new key mappings for the engine migration.15+// legacyEngineAlias defines the single metadata source for old engine config keys.
14-// Keys with empty new key ("") are dropped (not migrated).16+// Dropped aliases are detected as legacy input but are not written to a new target.
15-// The source section is the first part of the old key.17+type legacyEngineAlias struct {
16-type engineFieldMapping struct {18+ legacySection string
17- oldSection string19+ legacyField string
18- oldField string20+ targetSection string // "engine" or "engine.webkit"; empty when dropped
19- newSection string // "engine" or "engine.webkit"21+ targetField string // empty when dropped
20- newField string // empty means drop22+ dropped bool
21 }23 }
22 24
23-// engineUniversalMappings are fields that go into [engine] (not [engine.webkit]).25+func (a legacyEngineAlias) legacyKey() string {
24-var engineUniversalMappings = []engineFieldMapping{26+ return a.legacySection + "." + a.legacyField
27+}
28+
29+const (
30+ engineTargetSection = "engine"
31+ engineWebKitTargetSection = "engine.webkit"
32+)
33+
34+func legacyEngineTarget(legacySection, legacyField, targetSection, targetField string) legacyEngineAlias {
35+ return legacyEngineAlias{
36+ legacySection: legacySection,
37+ legacyField: legacyField,
38+ targetSection: targetSection,
39+ targetField: targetField,
40+ }
41+}
42+
43+func legacyEngineSameTarget(legacySection, legacyField, targetSection string) legacyEngineAlias {
44+ return legacyEngineTarget(legacySection, legacyField, targetSection, legacyField)
45+}
46+
47+func droppedLegacyEngineAlias(legacySection, legacyField string) legacyEngineAlias {
48+ return legacyEngineAlias{legacySection: legacySection, legacyField: legacyField, dropped: true}
49+}
50+
51+// legacyEngineAliases is the canonical list of old-to-new engine config aliases.
52+var legacyEngineAliases = []legacyEngineAlias{
25 // [performance] -> [engine]53 // [performance] -> [engine]
26- {oldSection: "performance", oldField: "profile", newSection: "engine", newField: "profile"},54+ legacyEngineSameTarget("performance", "profile", engineTargetSection),
27- {oldSection: "performance", oldField: "zoom_cache_size", newSection: "engine", newField: "zoom_cache_size"},55+ legacyEngineSameTarget("performance", "zoom_cache_size", engineTargetSection),
28- {oldSection: "performance", oldField: "webview_pool_prewarm_count", newSection: "engine", newField: "pool_prewarm_count"},56+ legacyEngineTarget("performance", "webview_pool_prewarm_count", engineTargetSection, "pool_prewarm_count"),
29 // [privacy] -> [engine]57 // [privacy] -> [engine]
30- {oldSection: "privacy", oldField: "cookie_policy", newSection: "engine", newField: "cookie_policy"},58+ legacyEngineSameTarget("privacy", "cookie_policy", engineTargetSection),
31-}
32 59
33-// engineWebkitMappings are fields that go into [engine.webkit].
34-var engineWebkitMappings = []engineFieldMapping{
35 // [privacy] -> [engine.webkit]60 // [privacy] -> [engine.webkit]
36- {oldSection: "privacy", oldField: "itp_enabled", newSection: "engine.webkit", newField: "itp_enabled"},61+ legacyEngineSameTarget("privacy", "itp_enabled", engineWebKitTargetSection),
37 62
38 // [rendering] -> [engine.webkit] (rendering.mode is dropped)63 // [rendering] -> [engine.webkit] (rendering.mode is dropped)
39- {oldSection: "rendering", oldField: "disable_dmabuf_renderer", newSection: "engine.webkit", newField: "disable_dmabuf_renderer"},64+ droppedLegacyEngineAlias("rendering", "mode"),
40- {oldSection: "rendering", oldField: "force_compositing_mode", newSection: "engine.webkit", newField: "force_compositing_mode"},65+ legacyEngineSameTarget("rendering", "disable_dmabuf_renderer", engineWebKitTargetSection),
41- {oldSection: "rendering", oldField: "disable_compositing_mode", newSection: "engine.webkit", newField: "disable_compositing_mode"},66+ legacyEngineSameTarget("rendering", "force_compositing_mode", engineWebKitTargetSection),
42- {oldSection: "rendering", oldField: "gsk_renderer", newSection: "engine.webkit", newField: "gsk_renderer"},67+ legacyEngineSameTarget("rendering", "disable_compositing_mode", engineWebKitTargetSection),
43- {oldSection: "rendering", oldField: "disable_mipmaps", newSection: "engine.webkit", newField: "disable_mipmaps"},68+ legacyEngineSameTarget("rendering", "gsk_renderer", engineWebKitTargetSection),
44- {oldSection: "rendering", oldField: "prefer_gl", newSection: "engine.webkit", newField: "prefer_gl"},69+ legacyEngineSameTarget("rendering", "disable_mipmaps", engineWebKitTargetSection),
45- {oldSection: "rendering", oldField: "draw_compositing_indicators", newSection: "engine.webkit", newField: "draw_compositing_indicators"},70+ legacyEngineSameTarget("rendering", "prefer_gl", engineWebKitTargetSection),
46- {oldSection: "rendering", oldField: "show_fps", newSection: "engine.webkit", newField: "show_fps"},71+ legacyEngineSameTarget("rendering", "draw_compositing_indicators", engineWebKitTargetSection),
47- {oldSection: "rendering", oldField: "sample_memory", newSection: "engine.webkit", newField: "sample_memory"},72+ legacyEngineSameTarget("rendering", "show_fps", engineWebKitTargetSection),
48- {oldSection: "rendering", oldField: "debug_frames", newSection: "engine.webkit", newField: "debug_frames"},73+ legacyEngineSameTarget("rendering", "sample_memory", engineWebKitTargetSection),
74+ legacyEngineSameTarget("rendering", "debug_frames", engineWebKitTargetSection),
49 75
50 // [performance] -> [engine.webkit] (webkit-specific) — field names are identical76 // [performance] -> [engine.webkit] (webkit-specific) — field names are identical
51- {oldSection: "performance", oldField: "skia_cpu_painting_threads",77+ legacyEngineSameTarget("performance", "skia_cpu_painting_threads", engineWebKitTargetSection),
52- newSection: "engine.webkit", newField: "skia_cpu_painting_threads"},78+ legacyEngineSameTarget("performance", "skia_gpu_painting_threads", engineWebKitTargetSection),
53- {oldSection: "performance", oldField: "skia_gpu_painting_threads",79+ legacyEngineSameTarget("performance", "skia_enable_cpu_rendering", engineWebKitTargetSection),
54- newSection: "engine.webkit", newField: "skia_gpu_painting_threads"},80+ legacyEngineSameTarget("performance", "web_process_memory_limit_mb", engineWebKitTargetSection),
55- {oldSection: "performance", oldField: "skia_enable_cpu_rendering",81+ legacyEngineSameTarget("performance", "web_process_memory_poll_interval_sec", engineWebKitTargetSection),
56- newSection: "engine.webkit", newField: "skia_enable_cpu_rendering"},82+ legacyEngineSameTarget("performance", "web_process_memory_conservative_threshold", engineWebKitTargetSection),
57- {oldSection: "performance", oldField: "web_process_memory_limit_mb",83+ legacyEngineSameTarget("performance", "web_process_memory_strict_threshold", engineWebKitTargetSection),
58- newSection: "engine.webkit", newField: "web_process_memory_limit_mb"},84+ legacyEngineSameTarget("performance", "network_process_memory_limit_mb", engineWebKitTargetSection),
59- {oldSection: "performance", oldField: "web_process_memory_poll_interval_sec",85+ legacyEngineSameTarget("performance", "network_process_memory_poll_interval_sec", engineWebKitTargetSection),
60- newSection: "engine.webkit", newField: "web_process_memory_poll_interval_sec"},86+ legacyEngineSameTarget("performance", "network_process_memory_conservative_threshold", engineWebKitTargetSection),
61- {oldSection: "performance", oldField: "web_process_memory_conservative_threshold",87+ legacyEngineSameTarget("performance", "network_process_memory_strict_threshold", engineWebKitTargetSection),
62- newSection: "engine.webkit", newField: "web_process_memory_conservative_threshold"},
63- {oldSection: "performance", oldField: "web_process_memory_strict_threshold",
64- newSection: "engine.webkit", newField: "web_process_memory_strict_threshold"},
65- {oldSection: "performance", oldField: "network_process_memory_limit_mb",
66- newSection: "engine.webkit", newField: "network_process_memory_limit_mb"},
67- {oldSection: "performance", oldField: "network_process_memory_poll_interval_sec",
68- newSection: "engine.webkit", newField: "network_process_memory_poll_interval_sec"},
69- {oldSection: "performance", oldField: "network_process_memory_conservative_threshold",
70- newSection: "engine.webkit", newField: "network_process_memory_conservative_threshold"},
71- {oldSection: "performance", oldField: "network_process_memory_strict_threshold",
72- newSection: "engine.webkit", newField: "network_process_memory_strict_threshold"},
73 88
74 // [media] GStreamer fields -> [engine.webkit]89 // [media] GStreamer fields -> [engine.webkit]
75- {oldSection: "media", oldField: "force_vsync", newSection: "engine.webkit", newField: "force_vsync"},90+ legacyEngineSameTarget("media", "force_vsync", engineWebKitTargetSection),
76- {oldSection: "media", oldField: "gl_rendering_mode", newSection: "engine.webkit", newField: "gl_rendering_mode"},91+ legacyEngineSameTarget("media", "gl_rendering_mode", engineWebKitTargetSection),
77- {oldSection: "media", oldField: "gstreamer_debug_level", newSection: "engine.webkit", newField: "gstreamer_debug_level"},92+ legacyEngineSameTarget("media", "gstreamer_debug_level", engineWebKitTargetSection),
78 93
79 // [runtime] -> [engine.webkit]94 // [runtime] -> [engine.webkit]
80- {oldSection: "runtime", oldField: "prefix", newSection: "engine.webkit", newField: "prefix"},95+ legacyEngineSameTarget("runtime", "prefix", engineWebKitTargetSection),
96+}
97+
98+func legacyEngineAliasesForSection(section string) []legacyEngineAlias {
99+ aliases := make([]legacyEngineAlias, 0)
100+ for _, alias := range legacyEngineAliases {
101+ if alias.legacySection == section {
102+ aliases = append(aliases, alias)
103+ }
104+ }
105+ return aliases
81 }106 }
82 107
83-// oldSectionsToRemove are the sections that are fully removed after migration108+func legacyEngineSectionsToRemove() []string {
84-// (media is not here because it has fields that stay).109+ sections := make([]string, 0)
85-var oldSectionsToRemove = []string{"rendering", "performance", "privacy", "runtime"}110+ seen := make(map[string]struct{})
111+ for _, alias := range legacyEngineAliases {
112+ if alias.legacySection == "media" {
113+ continue
114+ }
115+ if _, ok := seen[alias.legacySection]; ok {
116+ continue
117+ }
118+ seen[alias.legacySection] = struct{}{}
119+ sections = append(sections, alias.legacySection)
120+ }
121+ return sections
122+}
123+
124+func legacyEngineInputSections() []string {
125+ sections := make([]string, 0)
126+ seen := make(map[string]struct{})
127+ for _, alias := range legacyEngineAliases {
128+ if _, ok := seen[alias.legacySection]; ok {
129+ continue
130+ }
131+ seen[alias.legacySection] = struct{}{}
132+ sections = append(sections, alias.legacySection)
133+ }
134+ return sections
135+}
136+
137+func legacyEngineInputSectionsMessage() string {
138+ sections := legacyEngineInputSections()
139+ formatted := make([]string, 0, len(sections))
140+ for _, section := range sections {
141+ formatted = append(formatted, "["+section+"]")
142+ }
143+ return strings.Join(formatted, ", ")
144+}
145+
146+func hasLegacyEngineAlias(raw map[string]any, alias legacyEngineAlias) bool {
147+ sectionAny, exists := raw[alias.legacySection]
148+ if !exists {
149+ return false
150+ }
151+ sectionMap, ok := sectionAny.(map[string]any)
152+ if !ok {
153+ return false
154+ }
155+ _, exists = sectionMap[alias.legacyField]
156+ return exists
157+}
158+
159+func canonicalLegacyEngineValue(alias legacyEngineAlias, val any) any {
160+ if alias.targetSection == engineTargetSection && alias.targetField == "cookie_policy" {
161+ if cookiePolicy, ok := val.(string); ok && cookiePolicy == "accept_all" {
162+ return string(CookiePolicyAlways)
163+ }
164+ }
165+ return val
166+}
86 167
87 // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].168 // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].
88 // Returns true if migration was performed, false if already migrated or no old sections found.169 // Returns true if migration was performed, false if already migrated or no old sections found.
@@ -93,7 +174,7 @@ func MigrateToEngineConfig(configFile string) (bool, error) {
93 }174 }
94 175
95 if _, exists := raw["engine"]; exists {176 if _, exists := raw["engine"]; exists {
96- for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {177+ for _, section := range legacyEngineSectionsToRemove() {
97 if _, hasLegacy := raw[section]; hasLegacy {178 if _, hasLegacy := raw[section]; hasLegacy {
98 return false, fmt.Errorf(179 return false, fmt.Errorf(
99 "mixed old/new config: [engine] coexists with legacy [%s]; "+180 "mixed old/new config: [engine] coexists with legacy [%s]; "+
@@ -102,16 +183,13 @@ func MigrateToEngineConfig(configFile string) (bool, error) {
102 )183 )
103 }184 }
104 }185 }
105- // Check for deprecated media keys that moved to [engine.webkit].186+ for _, alias := range legacyEngineAliasesForSection("media") {
106- if mediaRaw, ok := raw["media"].(map[string]any); ok {187+ if hasLegacyEngineAlias(raw, alias) {
107- for _, key := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {188+ return false, fmt.Errorf(
108- if _, has := mediaRaw[key]; has {189+ "mixed old/new config: [engine] coexists with deprecated "+
109- return false, fmt.Errorf(190+ "media.%s; remove it from [media] (now in [engine.webkit])",
110- "mixed old/new config: [engine] coexists with deprecated "+191+ alias.legacyField,
111- "media.%s; remove it from [media] (now in [engine.webkit])",192+ )
112- key,
113- )
114- }
115 }193 }
116 }194 }
117 return false, nil // already migrated195 return false, nil // already migrated
@@ -150,18 +228,14 @@ func readRawTOML(path string) (map[string]any, error) {
150 228
151 // hasOldEngineSections checks if the raw config has old sections that need migration.229 // hasOldEngineSections checks if the raw config has old sections that need migration.
152 func hasOldEngineSections(raw map[string]any) bool {230 func hasOldEngineSections(raw map[string]any) bool {
153- for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {231+ for _, section := range legacyEngineSectionsToRemove() {
154 if _, exists := raw[section]; exists {232 if _, exists := raw[section]; exists {
155 return true233 return true
156 }234 }
157 }235 }
158- if mediaAny, exists := raw["media"]; exists {236+ for _, alias := range legacyEngineAliasesForSection("media") {
159- if media, ok := mediaAny.(map[string]any); ok {237+ if hasLegacyEngineAlias(raw, alias) {
160- for _, f := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {238+ return true
161- if _, has := media[f]; has {
162- return true
163- }
164- }
165 }239 }
166 }240 }
167 return false241 return false
@@ -172,14 +246,20 @@ func applyEngineMappings(raw map[string]any) {
172 engineMap := make(map[string]any)246 engineMap := make(map[string]any)
173 webkitMap := make(map[string]any)247 webkitMap := make(map[string]any)
174 248
175- for _, m := range engineUniversalMappings {249+ for _, alias := range legacyEngineAliases {
176- if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {250+ if alias.dropped {
177- engineMap[m.newField] = val251+ continue
178 }252 }
179- }253+ val := getSectionField(raw, alias.legacySection, alias.legacyField)
180- for _, m := range engineWebkitMappings {254+ if val == nil {
181- if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {255+ continue
182- webkitMap[m.newField] = val256+ }
257+ val = canonicalLegacyEngineValue(alias, val)
258+ switch alias.targetSection {
259+ case engineTargetSection:
260+ engineMap[alias.targetField] = val
261+ case engineWebKitTargetSection:
262+ webkitMap[alias.targetField] = val
183 }263 }
184 }264 }
185 265
@@ -189,14 +269,14 @@ func applyEngineMappings(raw map[string]any) {
189 }269 }
190 raw["engine"] = engineMap270 raw["engine"] = engineMap
191 271
192- for _, section := range oldSectionsToRemove {272+ for _, section := range legacyEngineSectionsToRemove() {
193 delete(raw, section)273 delete(raw, section)
194 }274 }
195 275
196 stripMigratedMediaFields(raw)276 stripMigratedMediaFields(raw)
197 }277 }
198 278
199-// stripMigratedMediaFields removes GStreamer fields from [media] while keeping the rest.279+// stripMigratedMediaFields removes migrated fields from [media] while keeping the rest.
200 func stripMigratedMediaFields(raw map[string]any) {280 func stripMigratedMediaFields(raw map[string]any) {
201 mediaAny, exists := raw["media"]281 mediaAny, exists := raw["media"]
202 if !exists {282 if !exists {
@@ -206,9 +286,9 @@ func stripMigratedMediaFields(raw map[string]any) {
206 if !ok {286 if !ok {
207 return287 return
208 }288 }
209- delete(media, "force_vsync")289+ for _, alias := range legacyEngineAliasesForSection("media") {
210- delete(media, "gl_rendering_mode")290+ delete(media, alias.legacyField)
211- delete(media, "gstreamer_debug_level")291+ }
212 if len(media) == 0 {292 if len(media) == 0 {
213 delete(raw, "media")293 delete(raw, "media")
214 }294 }
M internal/infrastructure/config/migrate_engine_test.go +73 -2
@@ -89,8 +89,8 @@ func TestMigrateToEngineConfig_OldFormat(t *testing.T) {
89 assert.Equal(t, int64(50), engine["zoom_cache_size"])89 assert.Equal(t, int64(50), engine["zoom_cache_size"])
90 assert.Equal(t, int64(2), engine["pool_prewarm_count"])90 assert.Equal(t, int64(2), engine["pool_prewarm_count"])
91 91
92- // [privacy] -> [engine] universal field92+ // [privacy] -> [engine] universal field, translated to canonical schema value
93- assert.Equal(t, "accept_all", engine["cookie_policy"])93+ assert.Equal(t, "always", engine["cookie_policy"])
94 94
95 // [engine.webkit] section95 // [engine.webkit] section
96 webkitAny, ok := engine["webkit"]96 webkitAny, ok := engine["webkit"]
@@ -253,6 +253,77 @@ disable_dmabuf_renderer = false
253 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])253 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
254 }254 }
255 255
256+func TestLegacyEngineAliasesMetadataCoverage(t *testing.T) {
257+ require.NotEmpty(t, legacyEngineAliases)
258+
259+ aliases := make(map[string]legacyEngineAlias)
260+ for _, alias := range legacyEngineAliases {
261+ key := alias.legacyKey()
262+ require.NotEmpty(t, alias.legacySection, "%s should have a legacy section", key)
263+ require.NotEmpty(t, alias.legacyField, "%s should have a legacy field", key)
264+ require.NotContains(t, aliases, key, "duplicate metadata for %s", key)
265+ aliases[key] = alias
266+
267+ if alias.dropped {
268+ assert.Empty(t, alias.targetSection, "%s is dropped and should not have a target section", key)
269+ assert.Empty(t, alias.targetField, "%s is dropped and should not have a target field", key)
270+ continue
271+ }
272+
273+ require.Contains(t, []string{"engine", "engine.webkit"}, alias.targetSection, "%s should target a known engine section", key)
274+ require.NotEmpty(t, alias.targetField, "%s should have a canonical target field", key)
275+ }
276+
277+ for _, key := range []string{
278+ "media.force_vsync",
279+ "media.gl_rendering_mode",
280+ "media.gstreamer_debug_level",
281+ "runtime.prefix",
282+ "performance.profile",
283+ "privacy.cookie_policy",
284+ "privacy.itp_enabled",
285+ "rendering.disable_dmabuf_renderer",
286+ } {
287+ alias, ok := aliases[key]
288+ require.True(t, ok, "missing metadata for %s", key)
289+ require.False(t, alias.dropped, "%s should have a canonical target", key)
290+ }
291+
292+ renderingMode, ok := aliases["rendering.mode"]
293+ require.True(t, ok, "missing dropped metadata for rendering.mode")
294+ assert.True(t, renderingMode.dropped)
295+}
296+
297+func TestHasOldEngineSections_UsesMetadataForMigratedMediaKeys(t *testing.T) {
298+ for _, field := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
299+ raw := map[string]any{"media": map[string]any{field: true}}
300+ assert.True(t, hasOldEngineSections(raw), "media.%s should trigger engine migration", field)
301+ }
302+
303+ raw := map[string]any{"media": map[string]any{"hardware_decoding": true}}
304+ assert.False(t, hasOldEngineSections(raw), "non-migrated media keys should not trigger engine migration")
305+}
306+
307+func TestMigrateToEngineConfig_MixedEngineAndMigratedMediaKey(t *testing.T) {
308+ tmpDir := t.TempDir()
309+ configFile := filepath.Join(tmpDir, "config.toml")
310+
311+ content := `
312+[engine]
313+type = "webkit"
314+
315+[media]
316+gl_rendering_mode = "auto"
317+`
318+ err := os.WriteFile(configFile, []byte(content), 0o644)
319+ require.NoError(t, err)
320+
321+ migrated, err := MigrateToEngineConfig(configFile)
322+ require.Error(t, err)
323+ assert.False(t, migrated)
324+ assert.Contains(t, err.Error(), "media.gl_rendering_mode")
325+}
326+
256 func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {327 func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {
257 tmpDir := t.TempDir()328 tmpDir := t.TempDir()
258 configFile := filepath.Join(tmpDir, "config.toml")329 configFile := filepath.Join(tmpDir, "config.toml")
M internal/infrastructure/config/schema_provider.go +62 -0
@@ -18,6 +18,7 @@ const (
18 SectionContentFiltering = "Content Filtering"18 SectionContentFiltering = "Content Filtering"
19 SectionRendering = "Rendering"19 SectionRendering = "Rendering"
20 SectionMedia = "Media"20 SectionMedia = "Media"
21+ SectionPrivacy = "Privacy"
21 SectionUpdate = "Update"22 SectionUpdate = "Update"
22 SectionDmenu = "Dmenu"23 SectionDmenu = "Dmenu"
23 SectionDebug = "Debug"24 SectionDebug = "Debug"
@@ -78,6 +79,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
78 // Media section79 // Media section
79 keys = append(keys, p.getMediaKeys(defaults)...)80 keys = append(keys, p.getMediaKeys(defaults)...)
80 81
82+ // Privacy section
83+ keys = append(keys, p.getPrivacyKeys(defaults)...)
84+
81 // Update section85 // Update section
82 keys = append(keys, p.getUpdateKeys(defaults)...)86 keys = append(keys, p.getUpdateKeys(defaults)...)
83 87
@@ -271,6 +275,13 @@ func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo {
271 Description: "Capture browser console messages to log",275 Description: "Capture browser console messages to log",
272 Section: SectionLogging,276 Section: SectionLogging,
273 },277 },
278+ {
279+ Key: "logging.capture_gtk_logs",
280+ Type: "bool",
281+ Default: fmt.Sprintf("%t", defaults.Logging.CaptureGTKLogs),
282+ Description: "Capture GTK and GLib log messages",
283+ Section: SectionLogging,
284+ },
274 }285 }
275 }286 }
276 287
@@ -776,6 +787,14 @@ func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo
776 787
777 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {788 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
778 return []entity.ConfigKeyInfo{789 return []entity.ConfigKeyInfo{
790+ {
791+ Key: "engine.type",
792+ Type: "string",
793+ Default: defaults.Engine.Type,
794+ Description: "Browser engine selection; CEF is the default and WebKitGTK is the fallback",
795+ Values: []string{"cef", "webkit"},
796+ Section: SectionRendering,
797+ },
779 {798 {
780 Key: "engine.webkit.gsk_renderer",799 Key: "engine.webkit.gsk_renderer",
781 Type: "string",800 Type: "string",
@@ -874,6 +893,49 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo {
874 Description: "Show media capability warnings at startup",893 Description: "Show media capability warnings at startup",
875 Section: SectionMedia,894 Section: SectionMedia,
876 },895 },
896+ {
897+ Key: "engine.webkit.force_vsync",
898+ Type: "bool",
899+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ForceVSync),
900+ Description: "Force VSync for WebKit fallback video playback",
901+ Section: SectionMedia,
902+ },
903+ {
904+ Key: "engine.webkit.gl_rendering_mode",
905+ Type: "string",
906+ Default: string(defaults.Engine.WebKit.GLRenderingMode),
907+ Description: "WebKit fallback GStreamer/OpenGL API selection",
908+ Values: []string{"auto", "gles2", "gl3", "none"},
909+ Section: SectionMedia,
910+ },
911+ {
912+ Key: "engine.webkit.gstreamer_debug_level",
913+ Type: "int",
914+ Default: fmt.Sprintf("%d", defaults.Engine.WebKit.GStreamerDebugLevel),
915+ Description: "WebKit fallback GStreamer debug verbosity",
916+ Range: "0-5",
917+ Section: SectionMedia,
918+ },
919+ }
920+}
921+
922+func (*SchemaProvider) getPrivacyKeys(defaults *Config) []entity.ConfigKeyInfo {
923+ return []entity.ConfigKeyInfo{
924+ {
925+ Key: "engine.cookie_policy",
926+ Type: "string",
927+ Default: string(defaults.Engine.CookiePolicy),
928+ Description: "Cookie acceptance policy",
929+ Values: []string{"always", "no_third_party", "never"},
930+ Section: SectionPrivacy,
931+ },
932+ {
933+ Key: "engine.webkit.itp_enabled",
934+ Type: "bool",
935+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ITPEnabled),
936+ Description: "Enable WebKit fallback Intelligent Tracking Prevention",
937+ Section: SectionPrivacy,
938+ },
877 }939 }
878 }940 }
879 941
A internal/infrastructure/config/schema_provider_test.go +169 -0
@@ -0,0 +1,169 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "runtime"
8+ "sort"
9+ "strings"
10+ "testing"
11+
12+ "github.com/spf13/viper"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
17+ docKeys := configurationReferenceKeys(t)
18+
19+ var missing []string
20+ for _, key := range NewSchemaProvider().GetSchema() {
21+ if configurationReferenceCoversKey(docKeys, key.Key) {
22+ continue
23+ }
24+ missing = append(missing, key.Key)
25+ }
26+
27+ sort.Strings(missing)
28+ require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
29+}
30+
31+func configurationReferenceKeys(t *testing.T) map[string]struct{} {
32+ t.Helper()
33+
34+ _, filename, _, ok := runtime.Caller(0)
35+ require.True(t, ok, "locate current test file")
36+ docPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "docs", "reference", "configuration.md")
37+ data, err := os.ReadFile(docPath)
38+ require.NoError(t, err)
39+
40+ // Keep this split point aligned with the markdown heading so legacy-key rows
41+ // stay out of the canonical schema key coverage set.
42+ canonicalReference, _, _ := strings.Cut(string(data), "Legacy key migration:")
43+ rowRE := regexp.MustCompile(`(?m)^\| ` + "`" + `([^` + "`" + `]+)` + "`" + ` \|`)
44+ keys := make(map[string]struct{})
45+ for _, match := range rowRE.FindAllStringSubmatch(canonicalReference, -1) {
46+ keys[match[1]] = struct{}{}
47+ }
48+ return keys
49+}
50+
51+func configurationReferenceCoversKey(docKeys map[string]struct{}, schemaKey string) bool {
52+ if _, ok := docKeys[schemaKey]; ok {
53+ return true
54+ }
55+
56+ schemaSegments := strings.Split(schemaKey, ".")
57+ for docKey := range docKeys {
58+ if configurationReferencePatternCoversKey(strings.Split(docKey, "."), schemaSegments) {
59+ return true
60+ }
61+ }
62+
63+ return false
64+}
65+
66+func configurationReferencePatternCoversKey(docSegments, schemaSegments []string) bool {
67+ for idx, schemaSegment := range schemaSegments {
68+ if schemaSegment == "*" {
69+ return idx == len(schemaSegments)-1 && len(docSegments) == idx+1
70+ }
71+ if idx >= len(docSegments) {
72+ return false
73+ }
74+ if isPlaceholderSegment(schemaSegment) && isPlaceholderSegment(docSegments[idx]) {
75+ continue
76+ }
77+ if docSegments[idx] != schemaSegment {
78+ return false
79+ }
80+ }
81+
82+ if len(docSegments) == len(schemaSegments) {
83+ return true
84+ }
85+ return configurationReferenceAllowsObjectFields(strings.Join(schemaSegments, ".")) &&
86+ len(docSegments) > len(schemaSegments)
87+}
88+
89+func isPlaceholderSegment(segment string) bool {
90+ return strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">")
91+}
92+
93+func configurationReferenceAllowsObjectFields(schemaKey string) bool {
94+ switch schemaKey {
95+ case "search_shortcuts.<key>", "workspace.floating_pane.profiles.<name>":
96+ return true
97+ default:
98+ return false
99+ }
100+}
101+
102+func TestConfigurationReferenceCoversKeyRequiresPlaceholderSegmentMatches(t *testing.T) {
103+ docKeys := map[string]struct{}{
104+ "workspace.floating_pane.profiles.<profile>.keys_extra": {},
105+ }
106+
107+ require.False(t, configurationReferenceCoversKey(
108+ docKeys,
109+ "workspace.floating_pane.profiles.<name>.keys",
110+ ))
111+}
112+
113+func TestConfigurationReferenceCoversKeyAllowsDocumentedPlaceholderSegments(t *testing.T) {
114+ docKeys := map[string]struct{}{
115+ "workspace.floating_pane.profiles.<profile>.keys": {},
116+ }
117+
118+ require.True(t, configurationReferenceCoversKey(
119+ docKeys,
120+ "workspace.floating_pane.profiles.<name>.keys",
121+ ))
122+}
123+
124+func TestConfigurationReferenceCoversKeyAllowsDocumentedObjectFields(t *testing.T) {
125+ docKeys := map[string]struct{}{
126+ "search_shortcuts.<name>.url": {},
127+ }
128+
129+ require.True(t, configurationReferenceCoversKey(docKeys, "search_shortcuts.<key>"))
130+}
131+
132+func TestSchemaProviderCoversViperDefaults(t *testing.T) {
133+ m := &Manager{viper: viper.New()}
134+ m.setDefaults()
135+
136+ schemaKeys := make(map[string]struct{})
137+ for _, key := range NewSchemaProvider().GetSchema() {
138+ require.NotEmpty(t, key.Key, "schema key should not be empty")
139+ require.NotContains(t, schemaKeys, key.Key, "duplicate schema key %s", key.Key)
140+ schemaKeys[key.Key] = struct{}{}
141+ }
142+
143+ defaultsCoveredByAggregateSchemaKeys := map[string]string{
144+ "appearance.light_palette": "appearance.light_palette.*",
145+ "appearance.dark_palette": "appearance.dark_palette.*",
146+ "search_shortcuts": "search_shortcuts.<key>",
147+ "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
148+ "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
149+ "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>",
150+ "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>",
151+ "session.session_mode.actions": "session.session_mode.actions.<action>",
152+ }
153+
154+ var missing []string
155+ for _, key := range m.viper.AllKeys() {
156+ if _, ok := schemaKeys[key]; ok {
157+ continue
158+ }
159+ if schemaKey, ok := defaultsCoveredByAggregateSchemaKeys[key]; ok {
160+ if _, hasAggregate := schemaKeys[schemaKey]; hasAggregate {
161+ continue
162+ }
163+ }
164+ missing = append(missing, key)
165+ }
166+
167+ sort.Strings(missing)
168+ require.Empty(t, missing, "every Viper default should have SchemaProvider metadata or an explicit aggregate schema key")
169+}
M internal/infrastructure/config/validation.go +103 -0
@@ -4,6 +4,8 @@ import (
4 "fmt"4 "fmt"
5 "math"5 "math"
6 "net/url"6 "net/url"
7+ "os"
8+ "path/filepath"
7 "sort"9 "sort"
8 "strings"10 "strings"
9 11
@@ -30,6 +32,7 @@ func validateConfig(config *Config) error {
30 validationErrors = append(validationErrors, validateLogging(config)...)32 validationErrors = append(validationErrors, validateLogging(config)...)
31 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)33 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
32 validationErrors = append(validationErrors, validateOmnibox(config)...)34 validationErrors = append(validationErrors, validateOmnibox(config)...)
35+ validationErrors = append(validationErrors, validateEngine(config)...)
33 validationErrors = append(validationErrors, validateRendering(config)...)36 validationErrors = append(validationErrors, validateRendering(config)...)
34 validationErrors = append(validationErrors, validatePrivacy(config)...)37 validationErrors = append(validationErrors, validatePrivacy(config)...)
35 validationErrors = append(validationErrors, validateColorScheme(config)...)38 validationErrors = append(validationErrors, validateColorScheme(config)...)
@@ -443,6 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
443 446
444 func validateWorkspaceURLValue(fieldPath, value string) []string {447 func validateWorkspaceURLValue(fieldPath, value string) []string {
445 trimmed := strings.TrimSpace(value)448 trimmed := strings.TrimSpace(value)
449+ if isRelativeWorkspacePath(trimmed) {
450+ return []string{fmt.Sprintf(
451+ "%s relative local path cannot be resolved without config file context (got: %s)",
452+ fieldPath,
453+ value,
454+ )}
455+ }
456+ if isLocalPathLikeWorkspaceURL(trimmed) {
457+ if isExistingWorkspacePath(trimmed) {
458+ return nil
459+ }
460+ return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
461+ }
446 candidate := trimmed462 candidate := trimmed
447 parsed, err := url.Parse(candidate)463 parsed, err := url.Parse(candidate)
448 if err != nil || parsed.Scheme == "" {464 if err != nil || parsed.Scheme == "" {
@@ -465,6 +481,62 @@ func validateWorkspaceURLValue(fieldPath, value string) []string {
465 }481 }
466 }482 }
467 483
484+func isLocalPathLikeWorkspaceURL(value string) bool {
485+ return strings.HasPrefix(value, "/") ||
486+ strings.HasPrefix(value, "./") ||
487+ strings.HasPrefix(value, "../") ||
488+ strings.HasPrefix(value, "~/")
489+}
490+
491+func isExistingWorkspacePath(value string) bool {
492+ if value == "" || strings.Contains(value, "://") {
493+ return false
494+ }
495+ candidate := value
496+ if strings.HasPrefix(candidate, "~/") {
497+ homeDir, err := os.UserHomeDir()
498+ if err != nil || homeDir == "" {
499+ return false
500+ }
501+ candidate = filepath.Join(homeDir, strings.TrimPrefix(candidate, "~/"))
502+ }
503+ absPath, err := filepath.Abs(candidate)
504+ if err != nil {
505+ return false
506+ }
507+ _, err = os.Stat(absPath)
508+ return err == nil
509+}
510+
511+func isRelativeWorkspacePath(value string) bool {
512+ if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
513+ strings.Contains(value, "://") {
514+ return false
515+ }
516+ if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
517+ looksLikeBareRelativeWorkspacePath(value) {
518+ return true
519+ }
520+ return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
521+}
522+
523+func looksLikeBareRelativeWorkspacePath(value string) bool {
524+ if strings.ContainsAny(value, `/\`) {
525+ firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
526+ return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
527+ firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
528+ }
529+
530+ switch strings.ToLower(filepath.Ext(value)) {
531+ case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
532+ ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
533+ ".yaml", ".yml":
534+ return true
535+ default:
536+ return false
537+ }
538+}
539+
468 func validateOmnibox(config *Config) []string {540 func validateOmnibox(config *Config) []string {
469 var validationErrors []string541 var validationErrors []string
470 if config.Omnibox.MostVisitedDays < 0 {542 if config.Omnibox.MostVisitedDays < 0 {
@@ -484,6 +556,18 @@ func validateOmnibox(config *Config) []string {
484 return validationErrors556 return validationErrors
485 }557 }
486 558
559+func validateEngine(config *Config) []string {
560+ switch config.Engine.Type {
561+ case EngineTypeCEF, EngineTypeWebKit:
562+ return nil
563+ default:
564+ return []string{fmt.Sprintf(
565+ "engine.type must be one of: cef, webkit (got: %s)",
566+ config.Engine.Type,
567+ )}
568+ }
569+}
570+
487 func validateRendering(config *Config) []string {571 func validateRendering(config *Config) []string {
488 var validationErrors []string572 var validationErrors []string
489 573
@@ -499,6 +583,25 @@ func validateRendering(config *Config) []string {
499 )583 )
500 }584 }
501 585
586+ switch config.Engine.WebKit.GLRenderingMode {
587+ case GLRenderingModeAuto, GLRenderingModeGLES2, GLRenderingModeGL3, GLRenderingModeNone, "":
588+ default:
589+ validationErrors = append(
590+ validationErrors,
591+ fmt.Sprintf(
592+ "engine.webkit.gl_rendering_mode must be one of: auto, gles2, gl3, none (got: %s)",
593+ config.Engine.WebKit.GLRenderingMode,
594+ ),
595+ )
596+ }
597+
598+ if config.Engine.WebKit.GStreamerDebugLevel < 0 || config.Engine.WebKit.GStreamerDebugLevel > 5 {
599+ validationErrors = append(validationErrors, fmt.Sprintf(
600+ "engine.webkit.gstreamer_debug_level must be between 0 and 5 (got: %d)",
601+ config.Engine.WebKit.GStreamerDebugLevel,
602+ ))
603+ }
604+
502 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {605 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
503 validationErrors = append(validationErrors,606 validationErrors = append(validationErrors,
504 "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",607 "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
M internal/infrastructure/config/validation_test.go +216 -0
@@ -2,12 +2,67 @@ package config
2 2
3 import (3 import (
4 "math"4 "math"
5+ "os"
6+ "path/filepath"
5 "testing"7 "testing"
6 8
7 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
9 )11 )
10 12
13+func TestValidateConfig_EngineType(t *testing.T) {
14+ t.Setenv("DUMBER_ENGINE", "")
15+
16+ tests := []struct {
17+ name string
18+ engineType string
19+ wantErr bool
20+ }{
21+ {name: "cef", engineType: EngineTypeCEF, wantErr: false},
22+ {name: "webkit", engineType: EngineTypeWebKit, wantErr: false},
23+ {name: "empty is invalid", engineType: "", wantErr: true},
24+ {name: "invalid", engineType: "netscape", wantErr: true},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ cfg := DefaultConfig()
30+ cfg.Engine.Type = tt.engineType
31+
32+ err := validateConfig(cfg)
33+ if tt.wantErr {
34+ require.Error(t, err)
35+ assert.Contains(t, err.Error(), "engine.type")
36+ return
37+ }
38+ require.NoError(t, err)
39+ })
40+ }
41+}
42+
43+func TestValidateConfig_EngineTypeIgnoresEnvOverride(t *testing.T) {
44+ t.Run("invalid configured type is still rejected when env override is valid", func(t *testing.T) {
45+ t.Setenv("DUMBER_ENGINE", EngineTypeWebKit)
46+ cfg := DefaultConfig()
47+ cfg.Engine.Type = "netscape"
48+
49+ err := validateConfig(cfg)
50+
51+ require.Error(t, err)
52+ assert.Contains(t, err.Error(), "engine.type")
53+ })
54+
55+ t.Run("valid configured type is not rejected when env override is invalid", func(t *testing.T) {
56+ t.Setenv("DUMBER_ENGINE", "netscape")
57+ cfg := DefaultConfig()
58+ cfg.Engine.Type = EngineTypeCEF
59+
60+ err := validateConfig(cfg)
61+
62+ require.NoError(t, err)
63+ })
64+}
65+
11 func TestValidateConfig_EngineCookiePolicy(t *testing.T) {66 func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
12 tests := []struct {67 tests := []struct {
13 name string68 name string
@@ -36,6 +91,76 @@ func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
36 }91 }
37 }92 }
38 93
94+func TestValidateConfig_WebKitMediaSettings(t *testing.T) {
95+ tests := []struct {
96+ name string
97+ mutate func(*Config)
98+ wantErr bool
99+ wantField string
100+ }{
101+ {
102+ name: "valid gl rendering modes",
103+ mutate: func(cfg *Config) {
104+ cfg.Engine.WebKit.GLRenderingMode = GLRenderingModeGLES2
105+ },
106+ wantErr: false,
107+ },
108+ {
109+ name: "invalid gl rendering mode",
110+ mutate: func(cfg *Config) {
111+ cfg.Engine.WebKit.GLRenderingMode = GLRenderingMode("metal")
112+ },
113+ wantErr: true,
114+ wantField: "engine.webkit.gl_rendering_mode",
115+ },
116+ {
117+ name: "gstreamer debug level lower bound",
118+ mutate: func(cfg *Config) {
119+ cfg.Engine.WebKit.GStreamerDebugLevel = 0
120+ },
121+ wantErr: false,
122+ },
123+ {
124+ name: "gstreamer debug level upper bound",
125+ mutate: func(cfg *Config) {
126+ cfg.Engine.WebKit.GStreamerDebugLevel = 5
127+ },
128+ wantErr: false,
129+ },
130+ {
131+ name: "negative gstreamer debug level",
132+ mutate: func(cfg *Config) {
133+ cfg.Engine.WebKit.GStreamerDebugLevel = -1
134+ },
135+ wantErr: true,
136+ wantField: "engine.webkit.gstreamer_debug_level",
137+ },
138+ {
139+ name: "too high gstreamer debug level",
140+ mutate: func(cfg *Config) {
141+ cfg.Engine.WebKit.GStreamerDebugLevel = 6
142+ },
143+ wantErr: true,
144+ wantField: "engine.webkit.gstreamer_debug_level",
145+ },
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ cfg := DefaultConfig()
151+ tt.mutate(cfg)
152+
153+ err := validateConfig(cfg)
154+ if tt.wantErr {
155+ require.Error(t, err)
156+ assert.Contains(t, err.Error(), tt.wantField)
157+ return
158+ }
159+ require.NoError(t, err)
160+ })
161+ }
162+}
163+
39 func TestValidateConfig_CEFConfig(t *testing.T) {164 func TestValidateConfig_CEFConfig(t *testing.T) {
40 tests := []struct {165 tests := []struct {
41 name string166 name string
@@ -169,6 +294,97 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
169 }294 }
170 }295 }
171 296
297+func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298+ tmpDir := t.TempDir()
299+ path := filepath.Join(tmpDir, "page.html")
300+ require.NoError(t, os.WriteFile(path, []byte("ok"), 0644))
301+
302+ cfg := DefaultConfig()
303+ cfg.Workspace.NewPaneURL = path
304+
305+ require.NoError(t, validateConfig(cfg))
306+}
307+
308+func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) {
309+ // This test mutates process CWD; do not add t.Parallel here.
310+ tmpDir := t.TempDir()
311+ oldWD, err := os.Getwd()
312+ require.NoError(t, err)
313+ childDir := filepath.Join(tmpDir, "child")
314+ require.NoError(t, os.Mkdir(childDir, 0755))
315+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "page.html"), []byte("ok"), 0644))
316+ require.NoError(t, os.WriteFile(filepath.Join(childDir, "page.html"), []byte("ok"), 0644))
317+ require.NoError(t, os.Chdir(childDir))
318+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
319+
320+ for _, value := range []string{"./page.html", "../page.html"} {
321+ t.Run(value, func(t *testing.T) {
322+ cfg := DefaultConfig()
323+ cfg.Workspace.NewPaneURL = value
324+
325+ err := validateConfig(cfg)
326+ require.Error(t, err)
327+ assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved")
328+ })
329+ }
330+}
331+
332+func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) {
333+ for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} {
334+ t.Run(value, func(t *testing.T) {
335+ cfg := DefaultConfig()
336+ cfg.Workspace.NewPaneURL = value
337+
338+ err := validateConfig(cfg)
339+ require.Error(t, err)
340+ assert.Contains(t, err.Error(), "workspace.new_pane_url local path must exist")
341+ })
342+ }
343+}
344+
345+func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) {
346+ // This test mutates process CWD; do not add t.Parallel here.
347+ tmpDir := t.TempDir()
348+ oldWD, err := os.Getwd()
349+ require.NoError(t, err)
350+ require.NoError(t, os.Chdir(tmpDir))
351+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
352+ require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
353+ require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644))
354+
355+ for _, value := range []string{"README", "index.html"} {
356+ t.Run(value, func(t *testing.T) {
357+ cfg := DefaultConfig()
358+ cfg.Workspace.NewPaneURL = value
359+
360+ validationErr := validateConfig(cfg)
361+ require.Error(t, validationErr)
362+ assert.Contains(
363+ t,
364+ validationErr.Error(),
365+ "workspace.new_pane_url relative local path cannot be resolved",
366+ )
367+ })
368+ }
369+}
370+
371+func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
372+ // This test mutates process CWD; do not add t.Parallel here.
373+ tmpDir := t.TempDir()
374+ oldWD, err := os.Getwd()
375+ require.NoError(t, err)
376+ require.NoError(t, os.Chdir(tmpDir))
377+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
378+
379+ cfg := DefaultConfig()
380+ cfg.Workspace.NewPaneURL = "missing-local-file"
381+
382+ validationErr := validateConfig(cfg)
383+ require.Error(t, validationErr)
384+ assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
385+ assert.Contains(t, validationErr.Error(), "relative local path cannot be resolved without config file context")
386+}
387+
172 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {388 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
173 cfg := DefaultConfig()389 cfg := DefaultConfig()
174 cfg.Engine.Profile = ProfileDefault390 cfg.Engine.Profile = ProfileDefault
M internal/infrastructure/config/xdg_test.go +7 -0
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
5 "testing"6 "testing"
6 7
@@ -29,6 +30,12 @@ func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) {
29 t.Chdir(wd)30 t.Chdir(wd)
30 31
31 t.Setenv("ENV", "dev")32 t.Setenv("ENV", "dev")
33+ runtimeDir, err := os.MkdirTemp("", "dbr-")
34+ require.NoError(t, err)
35+ t.Cleanup(func() {
36+ _ = os.RemoveAll(runtimeDir)
37+ })
38+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
32 dirs, err := GetXDGDirs()39 dirs, err := GetXDGDirs()
33 require.NoError(t, err)40 require.NoError(t, err)
34 require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome)41 require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome)
M internal/infrastructure/desktop/browser_launch_relay.go +34 -0
@@ -186,6 +186,33 @@ func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
186 return conn.SetDeadline(deadline)186 return conn.SetDeadline(deadline)
187 }187 }
188 188
189+func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error {
190+ socketDir := filepath.Dir(socketPath)
191+ for dir := socketDir; ; dir = filepath.Dir(dir) {
192+ info, err := os.Stat(dir)
193+ if err != nil {
194+ return fmt.Errorf("stat browser launch dir: %w", err)
195+ }
196+ stat, ok := info.Sys().(*syscall.Stat_t)
197+ if !ok {
198+ return nil
199+ }
200+
201+ isSocketDir := dir == socketDir
202+ if stat.Uid != expectedUID {
203+ if isSocketDir || stat.Uid != 0 {
204+ return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID)
205+ }
206+ }
207+ if info.Mode().Perm()&0o022 != 0 && (isSocketDir || info.Mode()&os.ModeSticky == 0) {
208+ return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm())
209+ }
210+ if parent := filepath.Dir(dir); parent == dir {
211+ return nil
212+ }
213+ }
214+}
215+
189 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {216 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
190 socketPath, err := r.socketPath()217 socketPath, err := r.socketPath()
191 if err != nil {218 if err != nil {
@@ -195,6 +222,9 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
195 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {222 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
196 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)223 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
197 }224 }
225+ if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
226+ return nil, ownerErr
227+ }
198 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})228 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
199 if err != nil {229 if err != nil {
200 if !errors.Is(err, syscall.EADDRINUSE) {230 if !errors.Is(err, syscall.EADDRINUSE) {
@@ -209,6 +239,10 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
209 return nil, errors.New("browser launch relay already running")239 return nil, errors.New("browser launch relay already running")
210 }240 }
211 241
242+ if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
243+ return nil, ownerErr
244+ }
245+
212 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {246 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
213 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)247 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
214 }248 }
M internal/infrastructure/desktop/browser_launch_relay_test.go +38 -0
@@ -51,6 +51,44 @@ func shortTempDir(t *testing.T) string {
51 return dir51 return dir
52 }52 }
53 53
54+func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) {
55+ ipc := testIPC(shortTempDir(t))
56+ require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
57+
58+ require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())))
59+}
60+
61+func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) {
62+ ipc := testIPC(shortTempDir(t))
63+ require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
64+
65+ unexpectedUID := uint32(os.Geteuid() + 1)
66+ if unexpectedUID == uint32(os.Geteuid()) {
67+ unexpectedUID++
68+ }
69+
70+ err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID)
71+ require.Error(t, err)
72+ require.Contains(t, err.Error(), "browser launch dir owner")
73+}
74+
75+func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) {
76+ root := shortTempDir(t)
77+ ipc := runtimeprofile.IPCPaths{
78+ RuntimeDir: filepath.Join(root, "dumber", "dev-abc123", "cef"),
79+ BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName),
80+ }
81+ require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
82+ require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777))
83+ t.Cleanup(func() {
84+ _ = os.Chmod(filepath.Join(root, "dumber"), 0o700)
85+ })
86+
87+ err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))
88+ require.Error(t, err)
89+ require.Contains(t, err.Error(), "browser launch dir permissions")
90+}
91+
54 func testIPC(root string) runtimeprofile.IPCPaths {92 func testIPC(root string) runtimeprofile.IPCPaths {
55 runtimeDir := filepath.Join(root, "runtime")93 runtimeDir := filepath.Join(root, "runtime")
56 return runtimeprofile.IPCPaths{94 return runtimeprofile.IPCPaths{
M internal/infrastructure/filesystem/adapter.go +28 -0
@@ -67,4 +67,32 @@ func (a *Adapter) RemoveAll(_ context.Context, path string) error {
67 return os.RemoveAll(path)67 return os.RemoveAll(path)
68 }68 }
69 69
70+// ResolveExistingPath expands supported user path syntax, absolutizes the path,
71+// and reports whether it exists in the OS filesystem.
72+func (a *Adapter) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
73+ if input == "" {
74+ return "", false, nil
75+ }
76+ path := input
77+ if len(path) >= 2 && path[0:2] == "~/" {
78+ home, err := os.UserHomeDir()
79+ if err != nil {
80+ return "", false, err
81+ }
82+ path = filepath.Join(home, path[2:])
83+ }
84+ absPath, err := filepath.Abs(path)
85+ if err != nil {
86+ return "", false, err
87+ }
88+ if _, err := os.Stat(absPath); err != nil {
89+ if os.IsNotExist(err) {
90+ return "", false, nil
91+ }
92+ return "", false, err
93+ }
94+ return absPath, true, nil
95+}
96+
70 var _ port.FileSystem = (*Adapter)(nil)97 var _ port.FileSystem = (*Adapter)(nil)
98+var _ port.LocalPathResolver = (*Adapter)(nil)
A internal/infrastructure/filesystem/local_path_resolver_test.go +71 -0
@@ -0,0 +1,71 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestResolveExistingPath(t *testing.T) {
11+ ctx := context.Background()
12+ adapter := New()
13+ tmpDir := t.TempDir()
14+ file := filepath.Join(tmpDir, "page.html")
15+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
16+ t.Fatal(err)
17+ }
18+ wantFile, err := filepath.EvalSymlinks(file)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+
23+ oldWD, err := os.Getwd()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := os.Chdir(tmpDir); err != nil {
28+ t.Fatal(err)
29+ }
30+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
31+
32+ t.Setenv("HOME", tmpDir)
33+
34+ tests := []struct {
35+ name string
36+ input string
37+ }{
38+ {name: "absolute", input: file},
39+ {name: "relative", input: "page.html"},
40+ {name: "home", input: "~/page.html"},
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ got, ok, err := adapter.ResolveExistingPath(ctx, tt.input)
46+ if err != nil {
47+ t.Fatalf("ResolveExistingPath returned error: %v", err)
48+ }
49+ if !ok {
50+ t.Fatalf("ResolveExistingPath ok=false")
51+ }
52+ gotFile, err := filepath.EvalSymlinks(got)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ if gotFile != wantFile {
57+ t.Fatalf("ResolveExistingPath(%q) = %q, want %q", tt.input, gotFile, wantFile)
58+ }
59+ })
60+ }
61+}
62+
63+func TestResolveExistingPathMissing(t *testing.T) {
64+ got, ok, err := New().ResolveExistingPath(context.Background(), filepath.Join(t.TempDir(), "missing.html"))
65+ if err != nil {
66+ t.Fatalf("ResolveExistingPath returned error: %v", err)
67+ }
68+ if ok || got != "" {
69+ t.Fatalf("ResolveExistingPath missing = (%q, %v), want empty false", got, ok)
70+ }
71+}
M internal/infrastructure/gtkutil/scale.go +1 -13
@@ -1,10 +1,6 @@
1 package gtkutil1 package gtkutil
2 2
3-import (3+import "math"
4- "math"
5-
6- "github.com/bnema/puregotk/v4/gtk"
7-)
8 4
9 // NormalizeScale clamps invalid GTK/GDK scale values to 1.5 // NormalizeScale clamps invalid GTK/GDK scale values to 1.
10 func NormalizeScale(scale float64) float64 {6 func NormalizeScale(scale float64) float64 {
@@ -14,14 +10,6 @@ func NormalizeScale(scale float64) float64 {
14 return scale10 return scale
15 }11 }
16 12
17-// WidgetFromNativePointer wraps a native GTK widget pointer.
18-func WidgetFromNativePointer(ptr uintptr) *gtk.Widget {
19- if ptr == 0 {
20- return nil
21- }
22- return gtk.WidgetNewFromInternalPtr(ptr)
23-}
24-
25 // DeviceToLogical converts a device-pixel coordinate into a logical coordinate.13 // DeviceToLogical converts a device-pixel coordinate into a logical coordinate.
26 func DeviceToLogical(value int32, scale float64) int32 {14 func DeviceToLogical(value int32, scale float64) int32 {
27 return int32(math.Floor(float64(value) / NormalizeScale(scale)))15 return int32(math.Floor(float64(value) / NormalizeScale(scale)))
M internal/infrastructure/persistence/sqlite/session_repo.go +4 -1
@@ -69,9 +69,12 @@ func (r *sessionRepo) GetActive(ctx context.Context) (*entity.Session, error) {
69 }69 }
70 70
71 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {71 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
72- if limit <= 0 {72+ if limit == 0 {
73 limit = 2073 limit = 20
74 }74 }
75+ if limit < 0 {
76+ limit = -1
77+ }
75 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))78 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
76 if err != nil {79 if err != nil {
77 return nil, err80 return nil, err
M internal/infrastructure/runtimeprofile/profile.go +39 -1
@@ -1,6 +1,8 @@
1 package runtimeprofile1 package runtimeprofile
2 2
3 import (3 import (
4+ "crypto/sha256"
5+ "encoding/hex"
4 "fmt"6 "fmt"
5 "path/filepath"7 "path/filepath"
6 "strings"8 "strings"
@@ -8,6 +10,7 @@ import (
8 10
9 const (11 const (
10 browserLaunchSocketName = "browser-launch.sock"12 browserLaunchSocketName = "browser-launch.sock"
13+ devIPCSocketPathLimit = 104
11 engineWebKit = "webkit"14 engineWebKit = "webkit"
12 engineCEF = "cef"15 engineCEF = "cef"
13 )16 )
@@ -82,7 +85,10 @@ func Resolve(input ResolveInput) (Profile, error) {
82 }85 }
83 root := filepath.Join(cwd, ".dev", "dumber")86 root := filepath.Join(cwd, ".dev", "dumber")
84 engineRoot := filepath.Join(root, "engines", engine)87 engineRoot := filepath.Join(root, "engines", engine)
85- ipcRoot := filepath.Join(root, "runtime", engine)88+ ipcRoot, err := devIPCRoot(input.Env, root, engine)
89+ if err != nil {
90+ return Profile{}, err
91+ }
86 return Profile{92 return Profile{
87 Mode: ModeDev,93 Mode: ModeDev,
88 Engine: engine,94 Engine: engine,
@@ -133,6 +139,38 @@ func Resolve(input ResolveInput) (Profile, error) {
133 }, nil139 }, nil
134 }140 }
135 141
142+func devIPCRoot(env func(string) string, root, engine string) (string, error) {
143+ rootHash := devIPCRootHash(root)
144+ for _, base := range []string{envValue(env, "XDG_RUNTIME_DIR"), envValue(env, "TMPDIR")} {
145+ if base == "" {
146+ continue
147+ }
148+ ipcRoot := devIPCRootFromBase(base, rootHash, engine)
149+ if len(filepath.Join(ipcRoot, browserLaunchSocketName)) < devIPCSocketPathLimit {
150+ return ipcRoot, nil
151+ }
152+ }
153+
154+ ipcRoot := filepath.Join(root, "runtime", engine)
155+ socketPath := filepath.Join(ipcRoot, browserLaunchSocketName)
156+ if len(socketPath) < devIPCSocketPathLimit {
157+ return ipcRoot, nil
158+ }
159+ return "", fmt.Errorf(
160+ "browser launch socket path too long: %d bytes; set XDG_RUNTIME_DIR or TMPDIR to a shorter runtime directory",
161+ len(socketPath),
162+ )
163+}
164+
165+func devIPCRootFromBase(base, rootHash, engine string) string {
166+ return filepath.Join(base, "dumber", "dev-"+rootHash, engine)
167+}
168+
169+func devIPCRootHash(root string) string {
170+ sum := sha256.Sum256([]byte(root))
171+ return hex.EncodeToString(sum[:])[:12]
172+}
173+
136 // normalizeEngine trims and lowercases the runtime engine name.174 // normalizeEngine trims and lowercases the runtime engine name.
137 // Supported engines are "webkit" and "cef"; any other value falls back to175 // Supported engines are "webkit" and "cef"; any other value falls back to
138 // the default "webkit" runtime namespace.176 // the default "webkit" runtime namespace.
M internal/infrastructure/runtimeprofile/profile_test.go +70 -0
@@ -2,6 +2,7 @@ package runtimeprofile
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -50,6 +51,75 @@ func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) {
50 require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)51 require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
51 }52 }
52 53
54+func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) {
55+ longWorktree := "/tmp/" + strings.Repeat("long-worktree-name-", 5) + "project"
56+
57+ profile, err := Resolve(ResolveInput{
58+ Env: func(key string) string {
59+ switch key {
60+ case "ENV":
61+ return "dev"
62+ case "XDG_RUNTIME_DIR":
63+ return "/run/user/1000"
64+ default:
65+ return ""
66+ }
67+ },
68+ Engine: "cef",
69+ CWD: func() (string, error) { return longWorktree, nil },
70+ })
71+
72+ require.NoError(t, err)
73+ require.Less(t, len(profile.IPC.BrowserLaunchSocket), 108)
74+ require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/run/user/1000/dumber/dev-"))
75+ require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock")
76+}
77+
78+func TestResolve_DevIPCPathFallsBackToTMPDIRWhenRuntimeDirWouldExceedSocketLimit(t *testing.T) {
79+ longRuntimeDir := "/tmp/" + strings.Repeat("long-runtime-dir-name-", 5) + "run"
80+
81+ profile, err := Resolve(ResolveInput{
82+ Env: func(key string) string {
83+ switch key {
84+ case "ENV":
85+ return "dev"
86+ case "XDG_RUNTIME_DIR":
87+ return longRuntimeDir
88+ case "TMPDIR":
89+ return "/run/user/1000"
90+ default:
91+ return ""
92+ }
93+ },
94+ Engine: "cef",
95+ CWD: func() (string, error) { return "/repo", nil },
96+ })
97+
98+ require.NoError(t, err)
99+ require.Less(t, len(profile.IPC.BrowserLaunchSocket), 104)
100+ require.NotContains(t, profile.IPC.BrowserLaunchSocket, longRuntimeDir)
101+ require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/run/user/1000/dumber/dev-"))
102+ require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock")
103+}
104+
105+func TestResolve_DevIPCPathErrorsWhenNoShortBaseExists(t *testing.T) {
106+ longWorktree := "/tmp/" + strings.Repeat("long-worktree-name-", 8) + "project"
107+
108+ _, err := Resolve(ResolveInput{
109+ Env: func(key string) string {
110+ if key == "ENV" {
111+ return "dev"
112+ }
113+ return ""
114+ },
115+ Engine: "cef",
116+ CWD: func() (string, error) { return longWorktree, nil },
117+ })
118+
119+ require.Error(t, err)
120+ require.Contains(t, err.Error(), "browser launch socket path too long")
121+}
122+
53 func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) {123 func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) {
54 devEnv := func(key string) string {124 devEnv := func(key string) string {
55 if key == "ENV" {125 if key == "ENV" {
M internal/infrastructure/updater/github.go +2 -8
@@ -275,14 +275,8 @@ func isRetryableRequestError(err error) bool {
275 }275 }
276 276
277 var netErr net.Error277 var netErr net.Error
278- if errors.As(err, &netErr) {278+ if errors.As(err, &netErr) && netErr.Timeout() {
279- if netErr.Timeout() {279+ return true
280- return true
281- }
282- //nolint:staticcheck // net.Error.Temporary is deprecated but still useful for transient detection
283- if netErr.Temporary() {
284- return true
285- }
286 }280 }
287 281
288 var opErr *net.OpError282 var opErr *net.OpError
M internal/infrastructure/updater/github_test.go +94 -0
@@ -6,11 +6,13 @@ import (
6 "encoding/hex"6 "encoding/hex"
7 "errors"7 "errors"
8 "io"8 "io"
9+ "net"
9 "net/http"10 "net/http"
10 "os"11 "os"
11 "path/filepath"12 "path/filepath"
12 "strings"13 "strings"
13 "sync/atomic"14 "sync/atomic"
15+ "syscall"
14 "testing"16 "testing"
15 "time"17 "time"
16 18
@@ -393,6 +395,31 @@ func TestRetryDelayForAttemptAddsJitter(t *testing.T) {
393 }395 }
394 }396 }
395 397
398+func TestIsRetryableRequestError(t *testing.T) {
399+ tests := []struct {
400+ name string
401+ err error
402+ want bool
403+ }{
404+ {name: "nil", err: nil, want: false},
405+ {name: "context canceled", err: context.Canceled, want: false},
406+ {name: "context deadline exceeded", err: context.DeadlineExceeded, want: true},
407+ {name: "timeout net error", err: &net.DNSError{IsTimeout: true}, want: true},
408+ {name: "non-timeout net error", err: &net.DNSError{Err: "lookup failed"}, want: false},
409+ {name: "transient errno through net op error", err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, want: true},
410+ {name: "non-transient errno", err: syscall.EINVAL, want: false},
411+ {name: "ordinary error", err: errors.New("ordinary"), want: false},
412+ }
413+
414+ for _, tt := range tests {
415+ t.Run(tt.name, func(t *testing.T) {
416+ if got := isRetryableRequestError(tt.err); got != tt.want {
417+ t.Fatalf("isRetryableRequestError(%v) = %v, want %v", tt.err, got, tt.want)
418+ }
419+ })
420+ }
421+}
422+
396 func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {423 func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
397 var calls int32424 var calls int32
398 tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {425 tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
@@ -427,6 +454,73 @@ func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
427 }454 }
428 }455 }
429 456
457+func TestGitHubCheckerDoRequestWithRetry_RetryableRequestError(t *testing.T) {
458+ var calls int32
459+ tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
460+ attempt := atomic.AddInt32(&calls, 1)
461+ if attempt == 1 {
462+ return nil, &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}
463+ }
464+ return testResponse(http.StatusOK, `{"tag_name":"v1.2.3"}`), nil
465+ })
466+
467+ checker := NewGitHubChecker()
468+ checker.client = &http.Client{Transport: tr}
469+ checker.randInt63 = func(_ int64) int64 { return 0 }
470+ checker.sleep = func(context.Context, time.Duration) error { return nil }
471+
472+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
473+ if err != nil {
474+ t.Fatalf("failed to create request: %v", err)
475+ }
476+
477+ resp, err := checker.doRequestWithRetry(context.Background(), req)
478+ if err != nil {
479+ t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
480+ }
481+ defer func() { _ = resp.Body.Close() }()
482+
483+ if resp.StatusCode != http.StatusOK {
484+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
485+ }
486+ if atomic.LoadInt32(&calls) != 2 {
487+ t.Fatalf("calls = %d, want 2", calls)
488+ }
489+}
490+
491+func TestGitHubCheckerDoRequestWithRetry_NoRetryOnNonRetryableRequestError(t *testing.T) {
492+ var calls int32
493+ wantErr := errors.New("ordinary request failure")
494+ tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
495+ atomic.AddInt32(&calls, 1)
496+ return nil, wantErr
497+ })
498+
499+ checker := NewGitHubChecker()
500+ checker.client = &http.Client{Transport: tr}
501+ checker.randInt63 = func(_ int64) int64 { return 0 }
502+ checker.sleep = func(context.Context, time.Duration) error { return nil }
503+
504+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
505+ if err != nil {
506+ t.Fatalf("failed to create request: %v", err)
507+ }
508+
509+ resp, err := checker.doRequestWithRetry(context.Background(), req)
510+ if err == nil {
511+ if resp != nil && resp.Body != nil {
512+ _ = resp.Body.Close()
513+ }
514+ t.Fatal("doRequestWithRetry() returned nil error, want request failure")
515+ }
516+ if !errors.Is(err, wantErr) {
517+ t.Fatalf("doRequestWithRetry() error = %v, want %v", err, wantErr)
518+ }
519+ if atomic.LoadInt32(&calls) != 1 {
520+ t.Fatalf("calls = %d, want 1", calls)
521+ }
522+}
523+
430 func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) {524 func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) {
431 var calls int32525 var calls int32
432 tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {526 tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
M internal/ui/app.go +46 -284
@@ -343,7 +343,7 @@ func (a *App) onActivate(ctx context.Context) {
343 a.setupPoolBackgroundColor(ctx)343 a.setupPoolBackgroundColor(ctx)
344 a.initLayoutInfrastructure()344 a.initLayoutInfrastructure()
345 345
346- if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {346+ if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {
347 log.Error().Err(err).Msg("failed to create main window")347 log.Error().Err(err).Msg("failed to create main window")
348 return348 return
349 }349 }
@@ -446,78 +446,6 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
446 pool.PrewarmAsync(ctx, 0)446 pool.PrewarmAsync(ctx, 0)
447 }447 }
448 448
449-func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {
450- log := logging.FromContext(ctx)
451- log.Debug().
452- Str("url_host", logging.SafeURLHost(initialURL)).
453- Bool("using_factory", a.browserWindowFactory != nil).
454- Int("window_count_before", len(a.browserWindows)).
455- Msg("ui: create browser window started")
456-
457- if a.browserWindowFactory != nil {
458- created, err := a.browserWindowFactory(ctx, initialURL)
459- if err != nil {
460- log.Warn().Err(err).
461- Str("url_host", logging.SafeURLHost(initialURL)).
462- Msg("ui: browser window factory failed")
463- return nil, err
464- }
465- created.ensureTabs()
466- a.wireBrowserWindowActivationTracking(created)
467- created.initChrome(ctx, a)
468- return created, nil
469- }
470-
471- runtimeCfg := a.runtimeConfigSnapshot().UI
472- mainWindow, err := window.New(ctx, a.gtkApp, runtimeCfg.Workspace.TabBarPosition)
473- if err != nil {
474- log.Warn().Err(err).
475- Str("url_host", logging.SafeURLHost(initialURL)).
476- Msg("ui: GTK browser window shell creation failed")
477- return nil, err
478- }
479- browserWindow := &browserWindow{
480- id: a.generateWindowID(),
481- initialURL: initialURL,
482- tabs: entity.NewTabList(),
483- mainWindow: mainWindow,
484- }
485- a.wireBrowserWindowActivationTracking(browserWindow)
486- if a.mainWindow == nil {
487- a.mainWindow = mainWindow
488- }
489-
490- closeRequestCb := func(_ gtk.Window) bool {
491- log.Info().Msg("browser window close requested")
492- a.removeBrowserWindow(browserWindow.id)
493- return false
494- }
495- mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
496-
497- a.initBrowserWindowOverlays(mainWindow, browserWindow, runtimeCfg)
498- a.wireBrowserWindowPermissionIndicator(browserWindow)
499- if a.kbDispatcher != nil {
500- a.initBrowserWindowInput(ctx, browserWindow)
501- }
502- if a.tabCoord != nil {
503- a.wireBrowserWindowTabBar(ctx, browserWindow)
504- }
505- browserWindow.initChrome(ctx, a)
506-
507- // Apply GTK CSS styling from theme manager.
508- if a.deps == nil || a.deps.Theme == nil {
509- return browserWindow, nil
510- }
511- if display := mainWindow.Window().GetDisplay(); display != nil {
512- a.deps.Theme.ApplyToDisplay(ctx, display)
513- }
514- log.Debug().
515- Str("window_id", browserWindow.id).
516- Str("url_host", logging.SafeURLHost(initialURL)).
517- Msg("ui: create browser window completed")
518- return browserWindow, nil
519-}
520-
521 func (a *App) initBrowserWindowOverlays(mainWindow *window.MainWindow, browserWindow *browserWindow, runtimeCfg entity.RuntimeUIConfig) {449 func (a *App) initBrowserWindowOverlays(mainWindow *window.MainWindow, browserWindow *browserWindow, runtimeCfg entity.RuntimeUIConfig) {
522 // Create permission popup and dialog presenter450 // Create permission popup and dialog presenter
523 if a.deps != nil && a.deps.PermissionUC != nil {451 if a.deps != nil && a.deps.PermissionUC != nil {
@@ -830,192 +758,6 @@ func (a *App) closeBrowserLaunchRelayListener() {
830 a.browserLaunchRelayCloser = nil758 a.browserLaunchRelayCloser = nil
831 }759 }
832 760
833-func (a *App) openFreshWindow(ctx context.Context, url string) error {
834- log := logging.FromContext(ctx)
835- log.Debug().
836- Str("url_host", logging.SafeURLHost(url)).
837- Int("window_count_before", len(a.browserWindows)).
838- Bool("has_tab_coord", a.tabCoord != nil).
839- Bool("has_tabs_uc", a.tabsUC != nil).
840- Msg("ui: open fresh window started")
841-
842- created, err := a.createBrowserWindow(ctx, url)
843- if err != nil {
844- log.Warn().Err(err).
845- Str("url_host", logging.SafeURLHost(url)).
846- Int("window_count_after", len(a.browserWindows)).
847- Msg("ui: open fresh window could not create browser window")
848- return err
849- }
850- log.Debug().
851- Str("window_id", created.id).
852- Str("url_host", logging.SafeURLHost(url)).
853- Msg("ui: open fresh window created browser window shell")
854- a.registerBrowserWindow(created)
855- log.Debug().
856- Str("window_id", created.id).
857- Int("window_count_after_register", len(a.browserWindows)).
858- Msg("ui: open fresh window registered browser window")
859-
860- if len(a.browserWindows) == 1 {
861- log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window")
862- a.activateBrowserWindow(created)
863- return nil
864- }
865-
866- var openErr error
867- path := "tabs_uc"
868- if a.tabCoord != nil {
869- path = "tab_coord"
870- log.Debug().Str("window_id", created.id).Msg("ui: creating initial tab for fresh window via tab coordinator")
871- openErr = a.openFreshWindowWithTabCoord(ctx, url, created)
872- } else if a.tabsUC == nil {
873- log.Debug().Str("window_id", created.id).Msg("ui: activating fresh browser window without tab use case")
874- a.activateBrowserWindow(created)
875- return nil
876- } else {
877- log.Debug().Str("window_id", created.id).Msg("ui: creating initial tab for fresh window via tabs use case")
878- openErr = a.openFreshWindowWithTabsUC(ctx, url, created)
879- }
880- if openErr != nil {
881- log.Warn().Err(openErr).
882- Str("window_id", created.id).
883- Str("path", path).
884- Int("window_count_after", len(a.browserWindows)).
885- Msg("ui: open fresh window initial tab creation failed")
886- return openErr
887- }
888- log.Debug().
889- Str("window_id", created.id).
890- Str("path", path).
891- Msg("ui: activating fresh browser window after tab creation")
892- a.activateBrowserWindow(created)
893- return nil
894-}
895-
896-func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
897- log := logging.FromContext(ctx)
898- previousTarget := a.tabCoord.CurrentTarget()
899- target := a.ensureTabTargetForBrowserWindow(created)
900- a.tabCoord.SetCurrentTarget(target)
901- defer func() {
902- a.tabCoord.SetCurrentTarget(previousTarget)
903- }()
904-
905- log.Debug().
906- Str("window_id", created.id).
907- Str("url_host", logging.SafeURLHost(url)).
908- Msg("ui: tab coordinator create started for fresh window")
909- createdTab, createErr := a.tabCoord.Create(ctx, target, url)
910- if createErr != nil {
911- log.Warn().Err(createErr).
912- Str("window_id", created.id).
913- Msg("ui: tab coordinator create failed for fresh window; rolling back window")
914- a.removeBrowserWindow(created.id)
915- if created.mainWindow != nil {
916- created.mainWindow.Destroy()
917- }
918- return createErr
919- }
920- log.Debug().
921- Str("window_id", created.id).
922- Str("tab_id", string(createdTab.ID)).
923- Msg("ui: tab coordinator create completed for fresh window")
924- if a.workspaceViews[createdTab.ID] == nil {
925- log.Warn().
926- Str("window_id", created.id).
927- Str("tab_id", string(createdTab.ID)).
928- Msg("ui: fresh window tab has no workspace view; rolling back window")
929- if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
930- createdTabs.Remove(createdTab.ID)
931- }
932- if created.mainWindow != nil {
933- if tabBar := created.mainWindow.TabBar(); tabBar != nil {
934- tabBar.RemoveTab(createdTab.ID)
935- if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
936- tabBar.SetActive(activeTab.ID)
937- }
938- }
939- }
940- a.removeBrowserWindow(created.id)
941- if created.mainWindow != nil {
942- created.mainWindow.Destroy()
943- }
944- return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
945- }
946- a.setBrowserWindowForTab(createdTab.ID, created)
947- // Tab was already created in the per-window TabList via tabCoord.Create;
948- // active state is managed by TabList.SetActive.
949- if created.mainWindow != nil {
950- created.mainWindow.Show()
951- }
952- return nil
953-}
954-
955-func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error {
956- log := logging.FromContext(ctx)
957- log.Debug().
958- Str("window_id", created.id).
959- Str("url_host", logging.SafeURLHost(url)).
960- Msg("ui: tabs use case create started for fresh window")
961- output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
962- TabList: a.tabs,
963- InitialURL: url,
964- })
965- if err != nil {
966- log.Warn().Err(err).
967- Str("window_id", created.id).
968- Msg("ui: tabs use case create failed for fresh window; rolling back window")
969- a.removeBrowserWindow(created.id)
970- if created.mainWindow != nil {
971- created.mainWindow.Destroy()
972- }
973- return err
974- }
975- log.Debug().
976- Str("window_id", created.id).
977- Str("tab_id", string(output.Tab.ID)).
978- Msg("ui: tabs use case create completed for fresh window")
979- a.setBrowserWindowForTab(output.Tab.ID, created)
980- // Mirror the tabsUC-created tab into the new browser window's tab list.
981- // This path uses the tabs usecase directly instead of tabCoord.Create,
982- // so the per-window TabList must be populated explicitly.
983- a.ensureTabListForBrowserWindow(created).Add(output.Tab)
984- if a.widgetFactory != nil {
985- a.createWorkspaceView(ctx, output.Tab)
986- a.switchWorkspaceView(ctx, output.Tab.ID)
987- }
988- if a.workspaceViews[output.Tab.ID] == nil {
989- log.Warn().
990- Str("window_id", created.id).
991- Str("tab_id", string(output.Tab.ID)).
992- Msg("ui: fresh window tabs use case tab has no workspace view; rolling back window")
993- if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
994- createdTabs.Remove(output.Tab.ID)
995- }
996- if a.tabs != nil {
997- a.tabs.Remove(output.Tab.ID)
998- }
999- if created.mainWindow != nil {
1000- if tabBar := created.mainWindow.TabBar(); tabBar != nil {
1001- tabBar.RemoveTab(output.Tab.ID)
1002- if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
1003- tabBar.SetActive(activeTab.ID)
1004- }
1005- }
1006- }
1007- a.removeBrowserWindow(created.id)
1008- if created.mainWindow != nil {
1009- created.mainWindow.Destroy()
1010- }
1011- return fmt.Errorf("workspace view not created for tab %s", output.Tab.ID)
1012- }
1013- if created.mainWindow != nil {
1014- created.mainWindow.Show()
1015- }
1016- return nil
1017-}
1018-
1019 func (a *App) initialWindowURL() string {761 func (a *App) initialWindowURL() string {
1020 if a.deps != nil {762 if a.deps != nil {
1021 return urlutil.ResolveBrowserStartupURL(a.deps.InitialURL)763 return urlutil.ResolveBrowserStartupURL(a.deps.InitialURL)
@@ -1437,12 +1179,13 @@ func (a *App) Cancel(ctx context.Context) {
1437 }1179 }
1438 1180
1439 type omniboxCallbacks struct {1181 type omniboxCallbacks struct {
1440- OnNavigate func(ctx context.Context, url string) error1182+ OnNavigate func(ctx context.Context, url string) error
1441- OnToast func(ctx context.Context, message string, level component.ToastLevel)1183+ NormalizeNavigationURL func(ctx context.Context, input string) string
1442- OnFocusIn func(entry *gtk.SearchEntry)1184+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
1443- OnFocusOut func()1185+ OnFocusIn func(entry *gtk.SearchEntry)
1444- OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool1186+ OnFocusOut func()
1445- OnAccentKeyRelease func(keyval uint)1187+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1188+ OnAccentKeyRelease func(keyval uint)
1446 }1189 }
1447 1190
1448 func buildOmniboxConfig(1191 func buildOmniboxConfig(
@@ -1464,22 +1207,23 @@ func buildOmniboxConfig(
1464 }1207 }
1465 1208
1466 return component.OmniboxConfig{1209 return component.OmniboxConfig{
1467- HistoryUC: deps.HistoryUC,1210+ HistoryUC: deps.HistoryUC,
1468- FavoritesUC: deps.FavoritesUC,1211+ FavoritesUC: deps.FavoritesUC,
1469- FaviconAdapter: faviconAdapter,1212+ FaviconAdapter: faviconAdapter,
1470- CopyURLUC: deps.CopyURLUC,1213+ CopyURLUC: deps.CopyURLUC,
1471- ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),1214+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1472- DefaultSearch: runtimeCfg.DefaultSearchEngine,1215+ DefaultSearch: runtimeCfg.DefaultSearchEngine,
1473- InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,1216+ NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1474- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,1217+ InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1475- SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,1218+ MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1476- UIScale: runtimeCfg.DefaultUIScale,1219+ SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1477- OnNavigate: callbacks.OnNavigate,1220+ UIScale: runtimeCfg.DefaultUIScale,
1478- OnToast: callbacks.OnToast,1221+ OnNavigate: callbacks.OnNavigate,
1479- OnFocusIn: callbacks.OnFocusIn,1222+ OnToast: callbacks.OnToast,
1480- OnFocusOut: callbacks.OnFocusOut,1223+ OnFocusIn: callbacks.OnFocusIn,
1481- OnAccentKeyPress: callbacks.OnAccentKeyPress,1224+ OnFocusOut: callbacks.OnFocusOut,
1482- OnAccentKeyRelease: callbacks.OnAccentKeyRelease,1225+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
1226+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
1483 }1227 }
1484 }1228 }
1485 1229
@@ -1506,10 +1250,19 @@ func NewStandaloneOmniboxRuntime(
1506 if deps != nil && deps.RuntimeConfig != nil {1250 if deps != nil && deps.RuntimeConfig != nil {
1507 runtimeCfg = deps.RuntimeConfig.Current().UI1251 runtimeCfg = deps.RuntimeConfig.Current().UI
1508 }1252 }
1253+ var localPathResolver port.LocalPathResolver
1254+ if deps != nil {
1255+ localPathResolver = deps.LocalPathResolver
1256+ }
1257+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver)
1258+ normalizeNavigationURL := func(navCtx context.Context, input string) string {
1259+ return navigationURLNormalizer.Normalize(navCtx, input)
1260+ }
1509 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{1261 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{
1510 OnNavigate: func(navCtx context.Context, url string) error {1262 OnNavigate: func(navCtx context.Context, url string) error {
1511 return handleStandaloneOmniboxNavigation(deps, navCtx, url)1263 return handleStandaloneOmniboxNavigation(deps, navCtx, url)
1512 },1264 },
1265+ NormalizeNavigationURL: normalizeNavigationURL,
1513 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {1266 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1514 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")1267 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
1515 },1268 },
@@ -1548,10 +1301,17 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
1548 return1301 return
1549 }1302 }
1550 1303
1304+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil)
1551 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{1305 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{
1552 OnNavigate: func(navCtx context.Context, url string) error {1306 OnNavigate: func(navCtx context.Context, url string) error {
1553 return a.navigateFromOmnibox(navCtx, url)1307 return a.navigateFromOmnibox(navCtx, url)
1554 },1308 },
1309+ NormalizeNavigationURL: func(navCtx context.Context, input string) string {
1310+ if a.panesUC != nil {
1311+ return a.panesUC.NormalizeNavigationURL(navCtx, input)
1312+ }
1313+ return navigationURLNormalizer.Normalize(navCtx, input)
1314+ },
1555 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {1315 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1556 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)1316 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
1557 },1317 },
@@ -2599,7 +2359,7 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
2599 }2359 }
2600 a.activateBrowserWindow(bw)2360 a.activateBrowserWindow(bw)
2601 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)2361 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2602- _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(newPaneURL))2362+ _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL)
2603 return err2363 return err
2604 }2364 }
2605 2365
@@ -2695,8 +2455,10 @@ func (a *App) createInitialTab(ctx context.Context) {
2695 }2455 }
2696 }2456 }
2697 2457
2458+ focusedWindow := a.lastFocusedBrowserWindow()
2459+
2698 // Create an initial tab using coordinator.2460 // Create an initial tab using coordinator.
2699- target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())2461+ target := a.ensureTabTargetForBrowserWindow(focusedWindow)
2700 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {2462 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
2701 log.Error().Err(err).Msg("failed to create initial tab")2463 log.Error().Err(err).Msg("failed to create initial tab")
2702 }2464 }
@@ -3507,7 +3269,7 @@ func (a *App) keyboardActions() dispatcher.KeyboardActions {
3507 return fmt.Errorf("newPaneURL is not configured")3269 return fmt.Errorf("newPaneURL is not configured")
3508 }3270 }
3509 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {3271 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3510- _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(newPaneURL))3272+ _, err := a.tabCoord.Create(ctx, target, newPaneURL)
3511 return err3273 return err
3512 })3274 })
3513 },3275 },
M internal/ui/app_browser_launch_test.go +12 -12
@@ -1104,7 +1104,7 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1104 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}1104 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1105 app := &App{1105 app := &App{
1106 tabs: entity.NewTabList(),1106 tabs: entity.NewTabList(),
1107- tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),1107+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1108 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},1108 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1109 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},1109 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1110 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},1110 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
@@ -1167,7 +1167,7 @@ func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
1167 ctx := context.Background()1167 ctx := context.Background()
1168 sessionID := entity.SessionID("session-empty-windows-fallback")1168 sessionID := entity.SessionID("session-empty-windows-fallback")
1169 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))1169 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
1170- tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" })1170+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" }, nil)
1171 windowTabs := entity.NewTabList()1171 windowTabs := entity.NewTabList()
1172 bw := &browserWindow{id: "window-1", tabs: windowTabs}1172 bw := &browserWindow{id: "window-1", tabs: windowTabs}
1173 app := &App{1173 app := &App{
@@ -1361,7 +1361,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1361 1361
1362 app := &App{1362 app := &App{
1363 tabs: entity.NewTabList(),1363 tabs: entity.NewTabList(),
1364- tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),1364+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1365 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},1365 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1366 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},1366 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1367 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {1367 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
@@ -1403,7 +1403,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1403 setWindowTabBar(t, oldWindow, newTestTabBarShell(t))1403 setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
1404 setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))1404 setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
1405 tabs := entity.NewTabList()1405 tabs := entity.NewTabList()
1406- tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })1406+ tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) }, nil)
1407 1407
1408 existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))1408 existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1409 existingTabs := entity.NewTabList()1409 existingTabs := entity.NewTabList()
@@ -2280,7 +2280,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
2280 deps: &Dependencies{},2280 deps: &Dependencies{},
2281 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),2281 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
2282 tabs: globalTabs,2282 tabs: globalTabs,
2283- tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),2283+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }, nil),
2284 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},2284 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2285 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},2285 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2286 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),2286 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
@@ -2341,7 +2341,7 @@ func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T
2341 deps: &Dependencies{},2341 deps: &Dependencies{},
2342 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),2342 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
2343 tabs: entity.NewTabList(),2343 tabs: entity.NewTabList(),
2344- tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }),2344+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }, nil),
2345 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},2345 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2346 lastFocusedWindowID: first.id,2346 lastFocusedWindowID: first.id,
2347 mainWindow: first.mainWindow,2347 mainWindow: first.mainWindow,
@@ -2973,7 +2973,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testin
2973 app := &App{2973 app := &App{
2974 deps: &Dependencies{},2974 deps: &Dependencies{},
2975 runtimeConfig: runtimeConfigStateForTest(cfg),2975 runtimeConfig: runtimeConfigStateForTest(cfg),
2976- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),2976+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
2977 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},2977 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2978 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},2978 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
2979 lastFocusedWindowID: first.id, // stale — should NOT be used2979 lastFocusedWindowID: first.id, // stale — should NOT be used
@@ -3016,7 +3016,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t
3016 app := &App{3016 app := &App{
3017 deps: &Dependencies{},3017 deps: &Dependencies{},
3018 runtimeConfig: runtimeConfigStateForTest(cfg),3018 runtimeConfig: runtimeConfigStateForTest(cfg),
3019- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),3019+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3020 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},3020 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
3021 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},3021 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
3022 lastFocusedWindowID: first.id, // stale — should NOT be used3022 lastFocusedWindowID: first.id, // stale — should NOT be used
@@ -3062,7 +3062,7 @@ func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
3062 app := &App{3062 app := &App{
3063 deps: &Dependencies{},3063 deps: &Dependencies{},
3064 runtimeConfig: runtimeConfigStateForTest(cfg),3064 runtimeConfig: runtimeConfigStateForTest(cfg),
3065- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),3065+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3066 browserWindows: map[string]*browserWindow{second.id: second},3066 browserWindows: map[string]*browserWindow{second.id: second},
3067 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},3067 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
3068 }3068 }
@@ -3089,7 +3089,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindo
3089 app := &App{3089 app := &App{
3090 deps: &Dependencies{},3090 deps: &Dependencies{},
3091 runtimeConfig: runtimeConfigStateForTest(cfg),3091 runtimeConfig: runtimeConfigStateForTest(cfg),
3092- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),3092+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3093 browserWindows: map[string]*browserWindow{second.id: second},3093 browserWindows: map[string]*browserWindow{second.id: second},
3094 lastFocusedWindowID: "window-1",3094 lastFocusedWindowID: "window-1",
3095 }3095 }
@@ -3119,7 +3119,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab
3119 app := &App{3119 app := &App{
3120 deps: &Dependencies{},3120 deps: &Dependencies{},
3121 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),3121 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3122- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),3122+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3123 browserWindows: map[string]*browserWindow{second.id: second},3123 browserWindows: map[string]*browserWindow{second.id: second},
3124 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},3124 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
3125 }3125 }
@@ -3145,7 +3145,7 @@ func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *te
3145 app := &App{3145 app := &App{
3146 deps: &Dependencies{},3146 deps: &Dependencies{},
3147 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),3147 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3148- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),3148+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3149 browserWindows: map[string]*browserWindow{second.id: second},3149 browserWindows: map[string]*browserWindow{second.id: second},
3150 lastFocusedWindowID: "window-1",3150 lastFocusedWindowID: "window-1",
3151 }3151 }
M internal/ui/app_omnibox_config_test.go +12 -1
@@ -1,6 +1,7 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
@@ -23,7 +24,11 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
23 },24 },
24 }25 }
25 26
26- got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{})27+ got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{
28+ NormalizeNavigationURL: func(_ context.Context, input string) string {
29+ return "normalized:" + input
30+ },
31+ })
27 if got.MostVisitedDays != 7 {32 if got.MostVisitedDays != 7 {
28 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)33 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)
29 }34 }
@@ -39,4 +44,10 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
39 if got.ShortcutsUC == nil {44 if got.ShortcutsUC == nil {
40 t.Fatal("ShortcutsUC is nil")45 t.Fatal("ShortcutsUC is nil")
41 }46 }
47+ if got.NormalizeNavigationURL == nil {
48+ t.Fatal("NormalizeNavigationURL is nil")
49+ }
50+ if normalized := got.NormalizeNavigationURL(context.Background(), "page.html"); normalized != "normalized:page.html" {
51+ t.Fatalf("NormalizeNavigationURL propagated callback = %q", normalized)
52+ }
42 }53 }
A internal/ui/app_window_lifecycle.go +283 -0
@@ -0,0 +1,283 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/window"
11+ "github.com/bnema/puregotk/v4/gtk"
12+)
13+
14+func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {
15+ log := logging.FromContext(ctx)
16+ log.Debug().
17+ Str("url_host", logging.SafeURLHost(initialURL)).
18+ Bool("using_factory", a.browserWindowFactory != nil).
19+ Int("window_count_before", len(a.browserWindows)).
20+ Msg("ui: create browser window started")
21+
22+ if a.browserWindowFactory != nil {
23+ created, err := a.browserWindowFactory(ctx, initialURL)
24+ if err != nil {
25+ log.Warn().Err(err).
26+ Str("url_host", logging.SafeURLHost(initialURL)).
27+ Msg("ui: browser window factory failed")
28+ return nil, err
29+ }
30+ created.ensureTabs()
31+ a.wireBrowserWindowActivationTracking(created)
32+ created.initChrome(ctx, a)
33+ return created, nil
34+ }
35+
36+ runtimeCfg := a.runtimeConfigSnapshot().UI
37+ mainWindow, err := window.New(ctx, a.gtkApp, runtimeCfg.Workspace.TabBarPosition)
38+ if err != nil {
39+ log.Warn().Err(err).
40+ Str("url_host", logging.SafeURLHost(initialURL)).
41+ Msg("ui: GTK browser window shell creation failed")
42+ return nil, err
43+ }
44+ browserWindow := &browserWindow{
45+ id: a.generateWindowID(),
46+ initialURL: initialURL,
47+ tabs: entity.NewTabList(),
48+ mainWindow: mainWindow,
49+ }
50+ a.wireBrowserWindowActivationTracking(browserWindow)
51+ if a.mainWindow == nil {
52+ a.mainWindow = mainWindow
53+ }
54+
55+ closeRequestCb := func(_ gtk.Window) bool {
56+ log.Info().Msg("browser window close requested")
57+ a.removeBrowserWindow(browserWindow.id)
58+ return false
59+ }
60+ mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
61+
62+ a.initBrowserWindowOverlays(mainWindow, browserWindow, runtimeCfg)
63+ a.wireBrowserWindowPermissionIndicator(browserWindow)
64+ if a.kbDispatcher != nil {
65+ a.initBrowserWindowInput(ctx, browserWindow)
66+ }
67+ if a.tabCoord != nil {
68+ a.wireBrowserWindowTabBar(ctx, browserWindow)
69+ }
70+ browserWindow.initChrome(ctx, a)
71+
72+ // Apply GTK CSS styling from theme manager.
73+ if a.deps == nil || a.deps.Theme == nil {
74+ return browserWindow, nil
75+ }
76+ if display := mainWindow.Window().GetDisplay(); display != nil {
77+ a.deps.Theme.ApplyToDisplay(ctx, display)
78+ }
79+ log.Debug().
80+ Str("window_id", browserWindow.id).
81+ Str("url_host", logging.SafeURLHost(initialURL)).
82+ Msg("ui: create browser window completed")
83+ return browserWindow, nil
84+}
85+
86+func (a *App) openInitialBrowserWindowShell(ctx context.Context, initialURL string) error {
87+ log := logging.FromContext(ctx)
88+ created, err := a.createBrowserWindow(ctx, initialURL)
89+ if err != nil {
90+ log.Warn().Err(err).
91+ Str("url_host", logging.SafeURLHost(initialURL)).
92+ Int("window_count_after", len(a.browserWindows)).
93+ Msg("ui: initial browser window shell creation failed")
94+ return err
95+ }
96+ a.registerBrowserWindow(created)
97+ log.Debug().
98+ Str("window_id", created.id).
99+ Str("url_host", logging.SafeURLHost(initialURL)).
100+ Msg("ui: initial browser window shell registered")
101+ a.activateBrowserWindow(created)
102+ return nil
103+}
104+
105+func (a *App) openFreshWindow(ctx context.Context, url string) error {
106+ log := logging.FromContext(ctx)
107+ log.Debug().
108+ Str("url_host", logging.SafeURLHost(url)).
109+ Int("window_count_before", len(a.browserWindows)).
110+ Bool("has_tab_coord", a.tabCoord != nil).
111+ Bool("has_tabs_uc", a.tabsUC != nil).
112+ Msg("ui: open fresh window started")
113+
114+ created, err := a.createBrowserWindow(ctx, url)
115+ if err != nil {
116+ log.Warn().Err(err).
117+ Str("url_host", logging.SafeURLHost(url)).
118+ Int("window_count_after", len(a.browserWindows)).
119+ Msg("ui: open fresh window could not create browser window")
120+ return err
121+ }
122+ log.Debug().
123+ Str("window_id", created.id).
124+ Str("url_host", logging.SafeURLHost(url)).
125+ Msg("ui: open fresh window created browser window shell")
126+ a.registerBrowserWindow(created)
127+ log.Debug().
128+ Str("window_id", created.id).
129+ Int("window_count_after_register", len(a.browserWindows)).
130+ Msg("ui: open fresh window registered browser window")
131+
132+ var openErr error
133+ path := "tabs_uc"
134+ if a.tabCoord != nil {
135+ path = "tab_coord"
136+ log.Debug().Str("window_id", created.id).Msg("ui: creating initial tab for fresh window via tab coordinator")
137+ openErr = a.openFreshWindowWithTabCoord(ctx, url, created)
138+ } else if a.tabsUC == nil {
139+ log.Debug().Str("window_id", created.id).Msg("ui: activating fresh browser window without tab use case")
140+ a.activateBrowserWindow(created)
141+ return nil
142+ } else {
143+ log.Debug().Str("window_id", created.id).Msg("ui: creating initial tab for fresh window via tabs use case")
144+ openErr = a.openFreshWindowWithTabsUC(ctx, url, created)
145+ }
146+ if openErr != nil {
147+ log.Warn().Err(openErr).
148+ Str("window_id", created.id).
149+ Str("path", path).
150+ Int("window_count_after", len(a.browserWindows)).
151+ Msg("ui: open fresh window initial tab creation failed")
152+ return openErr
153+ }
154+ log.Debug().
155+ Str("window_id", created.id).
156+ Str("path", path).
157+ Msg("ui: activating fresh browser window after tab creation")
158+ a.activateBrowserWindow(created)
159+ return nil
160+}
161+
162+func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
163+ log := logging.FromContext(ctx)
164+ previousTarget := a.tabCoord.CurrentTarget()
165+ target := a.ensureTabTargetForBrowserWindow(created)
166+ a.tabCoord.SetCurrentTarget(target)
167+ defer func() {
168+ a.tabCoord.SetCurrentTarget(previousTarget)
169+ }()
170+
171+ log.Debug().
172+ Str("window_id", created.id).
173+ Str("url_host", logging.SafeURLHost(url)).
174+ Msg("ui: tab coordinator create started for fresh window")
175+ createdTab, createErr := a.tabCoord.Create(ctx, target, url)
176+ if createErr != nil {
177+ log.Warn().Err(createErr).
178+ Str("window_id", created.id).
179+ Msg("ui: tab coordinator create failed for fresh window; rolling back window")
180+ a.removeBrowserWindow(created.id)
181+ if created.mainWindow != nil {
182+ created.mainWindow.Destroy()
183+ }
184+ return createErr
185+ }
186+ log.Debug().
187+ Str("window_id", created.id).
188+ Str("tab_id", string(createdTab.ID)).
189+ Msg("ui: tab coordinator create completed for fresh window")
190+ if a.workspaceViews[createdTab.ID] == nil {
191+ log.Warn().
192+ Str("window_id", created.id).
193+ Str("tab_id", string(createdTab.ID)).
194+ Msg("ui: fresh window tab has no workspace view; rolling back window")
195+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
196+ createdTabs.Remove(createdTab.ID)
197+ }
198+ if created.mainWindow != nil {
199+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
200+ tabBar.RemoveTab(createdTab.ID)
201+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
202+ tabBar.SetActive(activeTab.ID)
203+ }
204+ }
205+ }
206+ a.removeBrowserWindow(created.id)
207+ if created.mainWindow != nil {
208+ created.mainWindow.Destroy()
209+ }
210+ return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
211+ }
212+ a.setBrowserWindowForTab(createdTab.ID, created)
213+ // Tab was already created in the per-window TabList via tabCoord.Create;
214+ // active state is managed by TabList.SetActive.
215+ if created.mainWindow != nil {
216+ created.mainWindow.Show()
217+ }
218+ return nil
219+}
220+
221+func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error {
222+ log := logging.FromContext(ctx)
223+ log.Debug().
224+ Str("window_id", created.id).
225+ Str("url_host", logging.SafeURLHost(url)).
226+ Msg("ui: tabs use case create started for fresh window")
227+ output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
228+ TabList: a.tabs,
229+ InitialURL: url,
230+ })
231+ if err != nil {
232+ log.Warn().Err(err).
233+ Str("window_id", created.id).
234+ Msg("ui: tabs use case create failed for fresh window; rolling back window")
235+ a.removeBrowserWindow(created.id)
236+ if created.mainWindow != nil {
237+ created.mainWindow.Destroy()
238+ }
239+ return err
240+ }
241+ log.Debug().
242+ Str("window_id", created.id).
243+ Str("tab_id", string(output.Tab.ID)).
244+ Msg("ui: tabs use case create completed for fresh window")
245+ a.setBrowserWindowForTab(output.Tab.ID, created)
246+ // Mirror the tabsUC-created tab into the new browser window's tab list.
247+ // This path uses the tabs usecase directly instead of tabCoord.Create,
248+ // so the per-window TabList must be populated explicitly.
249+ a.ensureTabListForBrowserWindow(created).Add(output.Tab)
250+ if a.widgetFactory != nil || a.workspaceViewCreateOverride != nil {
251+ a.createWorkspaceView(ctx, output.Tab)
252+ a.switchWorkspaceView(ctx, output.Tab.ID)
253+ }
254+ if a.workspaceViews[output.Tab.ID] == nil {
255+ log.Warn().
256+ Str("window_id", created.id).
257+ Str("tab_id", string(output.Tab.ID)).
258+ Msg("ui: fresh window tabs use case tab has no workspace view; rolling back window")
259+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
260+ createdTabs.Remove(output.Tab.ID)
261+ }
262+ if a.tabs != nil {
263+ a.tabs.Remove(output.Tab.ID)
264+ }
265+ if created.mainWindow != nil {
266+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
267+ tabBar.RemoveTab(output.Tab.ID)
268+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
269+ tabBar.SetActive(activeTab.ID)
270+ }
271+ }
272+ }
273+ a.removeBrowserWindow(created.id)
274+ if created.mainWindow != nil {
275+ created.mainWindow.Destroy()
276+ }
277+ return fmt.Errorf("workspace view not created for tab %s", output.Tab.ID)
278+ }
279+ if created.mainWindow != nil {
280+ created.mainWindow.Show()
281+ }
282+ return nil
283+}
M internal/ui/browser_window_history_sidebar_test.go +1 -1
@@ -709,7 +709,7 @@ func TestApp_HistorySidebarConfig_OpenInNewPaneCallback(t *testing.T) {
709 709
710 bw := &browserWindow{id: "window-1", tabs: bwTabs}710 bw := &browserWindow{id: "window-1", tabs: bwTabs}
711 711
712- panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" })712+ panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" }, nil)
713 wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{713 wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
714 PanesUC: panesUC,714 PanesUC: panesUC,
715 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {715 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
M internal/ui/browser_window_test.go +71 -3
@@ -139,7 +139,7 @@ func TestTabCoordinator_CloseReleasesClosedTabWorkspaceWebViews(t *testing.T) {
139 app := &App{139 app := &App{
140 deps: &Dependencies{},140 deps: &Dependencies{},
141 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),141 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
142- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),142+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
143 contentCoord: contentCoord,143 contentCoord: contentCoord,
144 browserWindows: map[string]*browserWindow{bw.id: bw},144 browserWindows: map[string]*browserWindow{bw.id: bw},
145 tabs: entity.NewTabList(),145 tabs: entity.NewTabList(),
@@ -183,7 +183,7 @@ func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
183 app := &App{183 app := &App{
184 deps: &Dependencies{},184 deps: &Dependencies{},
185 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),185 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
186- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),186+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
187 contentCoord: contentCoord,187 contentCoord: contentCoord,
188 browserWindows: map[string]*browserWindow{bw.id: bw},188 browserWindows: map[string]*browserWindow{bw.id: bw},
189 tabs: entity.NewTabList(),189 tabs: entity.NewTabList(),
@@ -218,7 +218,7 @@ func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *test
218 app := &App{218 app := &App{
219 deps: &Dependencies{},219 deps: &Dependencies{},
220 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),220 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
221- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),221+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
222 contentCoord: contentCoord,222 contentCoord: contentCoord,
223 browserWindows: map[string]*browserWindow{bw.id: bw},223 browserWindows: map[string]*browserWindow{bw.id: bw},
224 tabs: entity.NewTabList(),224 tabs: entity.NewTabList(),
@@ -459,6 +459,74 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
459 }459 }
460 }460 }
461 461
462+func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) {
463+ app := &App{
464+ tabs: entity.NewTabList(),
465+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
466+ browserWindows: make(map[string]*browserWindow),
467+ }
468+ app.browserWindowFactory = func(_ context.Context, initialURL string) (*browserWindow, error) {
469+ return &browserWindow{id: "window-1", initialURL: initialURL, tabs: entity.NewTabList()}, nil
470+ }
471+
472+ if err := app.openInitialBrowserWindowShell(context.Background(), "https://example.com"); err != nil {
473+ t.Fatalf("openInitialBrowserWindowShell returned error: %v", err)
474+ }
475+ if got := len(app.browserWindows); got != 1 {
476+ t.Fatalf("browserWindows length = %d, want 1", got)
477+ }
478+ if app.lastFocusedWindowID != "window-1" {
479+ t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
480+ }
481+ if got := app.tabs.Count(); got != 0 {
482+ t.Fatalf("tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
483+ }
484+ windowTabs := app.browserWindows["window-1"].tabs
485+ require.NotNil(t, windowTabs)
486+ if got := windowTabs.Count(); got != 0 {
487+ t.Fatalf("window tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
488+ }
489+ require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
490+}
491+
492+func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
493+ app := &App{
494+ tabs: entity.NewTabList(),
495+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
496+ browserWindows: make(map[string]*browserWindow),
497+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
498+ windowForTab: map[entity.TabID]*browserWindow{},
499+ }
500+ app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
501+ fn()
502+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
503+ }
504+ app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
505+ return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
506+ }
507+ app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
508+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
509+ return true
510+ }
511+
512+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
513+ t.Fatalf("OpenFreshWindow returned error: %v", err)
514+ }
515+
516+ windowTabs := app.browserWindows["window-1"].tabs
517+ require.NotNil(t, windowTabs)
518+ require.Equal(t, 1, app.tabs.Count())
519+ require.Equal(t, 1, windowTabs.Count())
520+ tab := windowTabs.ActiveTab()
521+ require.NotNil(t, tab)
522+ require.NotNil(t, tab.Workspace)
523+ activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
524+ require.NotNil(t, activePane)
525+ require.Equal(t, "https://example.com", activePane.Pane.URI)
526+ require.NotNil(t, app.tabs.Find(tab.ID))
527+ require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
528+}
529+
462 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {530 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
463 app := &App{browserWindows: make(map[string]*browserWindow)}531 app := &App{browserWindows: make(map[string]*browserWindow)}
464 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {532 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
M internal/ui/component/omnibox.go +132 -51
@@ -39,6 +39,19 @@ type favoriteRowIndicatorUpdate struct {
39 ShowStarSlot bool39 ShowStarSlot bool
40 }40 }
41 41
42+type favoriteToggleTarget struct {
43+ url string
44+ title string
45+ favoriteID int64
46+ wasFavorite bool
47+}
48+
49+type favoriteToggleResultUpdate struct {
50+ Apply bool
51+ Index int
52+ IsFavorite bool
53+}
54+
42 // ViewMode distinguishes history search from favorites display.55 // ViewMode distinguishes history search from favorites display.
43 type ViewMode string56 type ViewMode string
44 57
@@ -108,16 +121,17 @@ type Omnibox struct {
108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText121 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
109 122
110 // Dependencies123 // Dependencies
111- historyUC *usecase.SearchHistoryUseCase124+ historyUC *usecase.SearchHistoryUseCase
112- favoritesUC *usecase.ManageFavoritesUseCase125+ favoritesUC *usecase.ManageFavoritesUseCase
113- faviconAdapter *adapter.FaviconAdapter126+ faviconAdapter *adapter.FaviconAdapter
114- copyURLUC *usecase.CopyURLUseCase127+ copyURLUC *usecase.CopyURLUseCase
115- shortcutsUC *usecase.SearchShortcutsUseCase128+ shortcutsUC *usecase.SearchShortcutsUseCase
116- defaultSearch string129+ defaultSearch string
117- initialBehavior entity.OmniboxInitialBehavior130+ normalizeNavigationURL func(ctx context.Context, input string) string
118- mostVisitedDays int131+ initialBehavior entity.OmniboxInitialBehavior
119- saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error132+ mostVisitedDays int
120- ctx context.Context133+ saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
134+ ctx context.Context
121 135
122 // Callbacks136 // Callbacks
123 onNavigate func(ctx context.Context, url string) error137 onNavigate func(ctx context.Context, url string) error
@@ -155,16 +169,19 @@ type Omnibox struct {
155 169
156 // OmniboxConfig holds configuration for creating an Omnibox.170 // OmniboxConfig holds configuration for creating an Omnibox.
157 type OmniboxConfig struct {171 type OmniboxConfig struct {
158- HistoryUC *usecase.SearchHistoryUseCase172+ HistoryUC *usecase.SearchHistoryUseCase
159- FavoritesUC *usecase.ManageFavoritesUseCase173+ FavoritesUC *usecase.ManageFavoritesUseCase
160- FaviconAdapter *adapter.FaviconAdapter174+ FaviconAdapter *adapter.FaviconAdapter
161- CopyURLUC *usecase.CopyURLUseCase175+ CopyURLUC *usecase.CopyURLUseCase
162- ShortcutsUC *usecase.SearchShortcutsUseCase176+ ShortcutsUC *usecase.SearchShortcutsUseCase
163- DefaultSearch string177+ DefaultSearch string
164- InitialBehavior entity.OmniboxInitialBehavior178+ // NormalizeNavigationURL resolves navigation input before search fallback.
165- MostVisitedDays int179+ // It is injected so local filesystem probing stays outside the domain URL package.
166- SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error180+ NormalizeNavigationURL func(ctx context.Context, input string) string
167- UIScale float64 // UI scale for favicon sizing181+ InitialBehavior entity.OmniboxInitialBehavior
182+ MostVisitedDays int
183+ SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
184+ UIScale float64 // UI scale for favicon sizing
168 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.185 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
169 OnNavigate func(ctx context.Context, url string) error186 OnNavigate func(ctx context.Context, url string) error
170 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification187 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
@@ -188,23 +205,24 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
188 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)205 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
189 206
190 o := &Omnibox{207 o := &Omnibox{
191- viewMode: ViewModeHistory,208+ viewMode: ViewModeHistory,
192- selectedIndex: -1,209+ selectedIndex: -1,
193- historyUC: cfg.HistoryUC,210+ historyUC: cfg.HistoryUC,
194- favoritesUC: cfg.FavoritesUC,211+ favoritesUC: cfg.FavoritesUC,
195- faviconAdapter: cfg.FaviconAdapter,212+ faviconAdapter: cfg.FaviconAdapter,
196- copyURLUC: cfg.CopyURLUC,213+ copyURLUC: cfg.CopyURLUC,
197- shortcutsUC: cfg.ShortcutsUC,214+ shortcutsUC: cfg.ShortcutsUC,
198- defaultSearch: cfg.DefaultSearch,215+ defaultSearch: cfg.DefaultSearch,
199- initialBehavior: cfg.InitialBehavior,216+ normalizeNavigationURL: cfg.NormalizeNavigationURL,
200- mostVisitedDays: cfg.MostVisitedDays,217+ initialBehavior: cfg.InitialBehavior,
201- saveInitialBehaviorFn: cfg.SaveInitialBehavior,218+ mostVisitedDays: cfg.MostVisitedDays,
202- onToast: cfg.OnToast,219+ saveInitialBehaviorFn: cfg.SaveInitialBehavior,
203- onAccentKeyPress: cfg.OnAccentKeyPress,220+ onToast: cfg.OnToast,
204- onAccentKeyRelease: cfg.OnAccentKeyRelease,221+ onAccentKeyPress: cfg.OnAccentKeyPress,
205- ctx: ctx,222+ onAccentKeyRelease: cfg.OnAccentKeyRelease,
206- uiScale: uiScale,223+ ctx: ctx,
207- sizeCfg: sizeCfg,224+ uiScale: uiScale,
225+ sizeCfg: sizeCfg,
208 }226 }
209 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {227 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
210 var cb glib.SourceFunc = func(uintptr) bool {228 var cb glib.SourceFunc = func(uintptr) bool {
@@ -2380,6 +2398,63 @@ func shouldPreferTypedURLNavigation(entryText string) bool {
2380 return url.LooksLikeURL(entryText)2398 return url.LooksLikeURL(entryText)
2381 }2399 }
2382 2400
2401+func resolveFavoriteToggleTarget(
2402+ mode ViewMode,
2403+ idx int,
2404+ suggestions []Suggestion,
2405+ favorites []Favorite,
2406+) (favoriteToggleTarget, bool) {
2407+ if mode == ViewModeHistory {
2408+ if idx < 0 || idx >= len(suggestions) {
2409+ return favoriteToggleTarget{}, false
2410+ }
2411+ s := suggestions[idx]
2412+ if s.URL == "" {
2413+ return favoriteToggleTarget{}, false
2414+ }
2415+ return favoriteToggleTarget{
2416+ url: s.URL,
2417+ title: s.Title,
2418+ wasFavorite: s.IsFavorite,
2419+ }, true
2420+ }
2421+
2422+ if idx < 0 || idx >= len(favorites) {
2423+ return favoriteToggleTarget{}, false
2424+ }
2425+ f := favorites[idx]
2426+ if f.ID == 0 {
2427+ return favoriteToggleTarget{}, false
2428+ }
2429+ return favoriteToggleTarget{
2430+ url: f.URL,
2431+ title: f.Title,
2432+ favoriteID: f.ID,
2433+ }, true
2434+}
2435+
2436+func resolveFavoriteToggleResultUpdate(
2437+ suggestions []Suggestion,
2438+ index int,
2439+ expectedURL string,
2440+ isFavorite bool,
2441+) favoriteToggleResultUpdate {
2442+ if index < 0 || index >= len(suggestions) {
2443+ return favoriteToggleResultUpdate{}
2444+ }
2445+ if suggestions[index].URL != expectedURL {
2446+ return favoriteToggleResultUpdate{}
2447+ }
2448+ return favoriteToggleResultUpdate{Apply: true, Index: index, IsFavorite: isFavorite}
2449+}
2450+
2451+func favoriteToggleErrorMessage(wasFavorite bool) string {
2452+ if wasFavorite {
2453+ return "Failed to remove favorite"
2454+ }
2455+ return "Failed to add favorite"
2456+}
2457+
2383 // toggleFavorite adds or removes the selected item from favorites.2458 // toggleFavorite adds or removes the selected item from favorites.
2384 // In History mode: adds the selected item to favorites2459 // In History mode: adds the selected item to favorites
2385 // In Favorites mode: removes the selected item from favorites2460 // In Favorites mode: removes the selected item from favorites
@@ -2410,19 +2485,20 @@ func (o *Omnibox) toggleFavorite() {
2410 log.Debug().Msg("toggle favorite: empty URL")2485 log.Debug().Msg("toggle favorite: empty URL")
2411 return2486 return
2412 }2487 }
2488+ target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2489+ if !ok {
2490+ return
2491+ }
2413 2492
2414 go func() {2493 go func() {
2415 ctx := o.ctx2494 ctx := o.ctx
2416 goLog := logging.FromContext(ctx)2495 goLog := logging.FromContext(ctx)
2417 2496
2418- result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)2497+ result, err := o.favoritesUC.Toggle(ctx, target.url, target.title)
2419 if err != nil {2498 if err != nil {
2420- goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")2499+ goLog.Error().Err(err).Str("url", target.url).Msg("failed to toggle favorite")
2421 if o.onToast != nil {2500 if o.onToast != nil {
2422- msg := "Failed to add favorite"2501+ msg := favoriteToggleErrorMessage(target.wasFavorite)
2423- if s.IsFavorite {
2424- msg = "Failed to remove favorite"
2425- }
2426 cb := glib.SourceFunc(func(_ uintptr) bool {2502 cb := glib.SourceFunc(func(_ uintptr) bool {
2427 o.onToast(ctx, msg, ToastError)2503 o.onToast(ctx, msg, ToastError)
2428 return false2504 return false
@@ -2434,14 +2510,15 @@ func (o *Omnibox) toggleFavorite() {
2434 2510
2435 // Update suggestion state2511 // Update suggestion state
2436 o.mu.Lock()2512 o.mu.Lock()
2437- if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {2513+ update := resolveFavoriteToggleResultUpdate(o.suggestions, idx, target.url, result.Added)
2438- o.suggestions[idx].IsFavorite = result.Added2514+ if update.Apply {
2515+ o.suggestions[update.Index].IsFavorite = update.IsFavorite
2439 }2516 }
2440 o.mu.Unlock()2517 o.mu.Unlock()
2441 2518
2442 // Update row CSS and show toast on GTK main thread2519 // Update row CSS and show toast on GTK main thread
2443 cb := glib.SourceFunc(func(_ uintptr) bool {2520 cb := glib.SourceFunc(func(_ uintptr) bool {
2444- o.updateRowFavoriteIndicator(idx, s.URL, result.Added)2521+ o.updateRowFavoriteIndicator(idx, target.url, result.Added)
2445 if o.onToast != nil {2522 if o.onToast != nil {
2446 o.onToast(ctx, result.Message, ToastSuccess)2523 o.onToast(ctx, result.Message, ToastSuccess)
2447 }2524 }
@@ -2461,17 +2538,21 @@ func (o *Omnibox) toggleFavorite() {
2461 log.Debug().Msg("remove favorite: invalid ID")2538 log.Debug().Msg("remove favorite: invalid ID")
2462 return2539 return
2463 }2540 }
2541+ target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2542+ if !ok {
2543+ return
2544+ }
2464 entryText := o.entry.GetText()2545 entryText := o.entry.GetText()
2465 2546
2466 go func() {2547 go func() {
2467 ctx := o.ctx2548 ctx := o.ctx
2468 log := logging.FromContext(ctx)2549 log := logging.FromContext(ctx)
2469 2550
2470- log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")2551+ log.Debug().Int64("id", target.favoriteID).Str("url", target.url).Msg("removing from favorites")
2471 2552
2472- err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))2553+ err := o.favoritesUC.Remove(ctx, entity.FavoriteID(target.favoriteID))
2473 if err != nil {2554 if err != nil {
2474- log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")2555+ log.Error().Err(err).Int64("id", target.favoriteID).Msg("failed to remove favorite")
2475 // Show error toast2556 // Show error toast
2476 if o.onToast != nil {2557 if o.onToast != nil {
2477 cb := glib.SourceFunc(func(_ uintptr) bool {2558 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -2483,7 +2564,7 @@ func (o *Omnibox) toggleFavorite() {
2483 return2564 return
2484 }2565 }
2485 2566
2486- log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")2567+ log.Info().Int64("id", target.favoriteID).Str("url", target.url).Msg("favorite removed from omnibox")
2487 2568
2488 // Refresh favorites list and show toast2569 // Refresh favorites list and show toast
2489 o.mu.RLock()2570 o.mu.RLock()
@@ -2617,7 +2698,7 @@ func (o *Omnibox) buildURL(text string) string {
2617 if o.shortcutsUC != nil {2698 if o.shortcutsUC != nil {
2618 shortcutURLs = o.shortcutsUC.ShortcutURLs()2699 shortcutURLs = o.shortcutsUC.ShortcutURLs()
2619 }2700 }
2620- return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)2701+ return usecase.BuildNavigationURL(o.ctx, text, o.normalizeNavigationURL, shortcutURLs, o.defaultSearch)
2621 }2702 }
2622 2703
2623 // toggleViewMode switches between history and favorites.2704 // toggleViewMode switches between history and favorites.
A internal/ui/component/omnibox_build_url_test.go +37 -0
@@ -0,0 +1,37 @@
1+package component
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestOmniboxBuildURLUsesNavigationNormalizerBeforeURLFallback(t *testing.T) {
9+ o := &Omnibox{
10+ ctx: context.Background(),
11+ defaultSearch: "https://search.example/?q=%s",
12+ normalizeNavigationURL: func(_ context.Context, input string) string {
13+ if input == "page.html" {
14+ return "file:///tmp/page.html"
15+ }
16+ return input
17+ },
18+ }
19+
20+ if got := o.buildURL("page.html"); got != "file:///tmp/page.html" {
21+ t.Fatalf("buildURL(page.html) = %q, want local file URL", got)
22+ }
23+}
24+
25+func TestOmniboxBuildURLLeavesBangShortcutsToSearchHandling(t *testing.T) {
26+ o := &Omnibox{
27+ ctx: context.Background(),
28+ defaultSearch: "https://search.example/?q=%s",
29+ normalizeNavigationURL: func(_ context.Context, _ string) string {
30+ return "file:///tmp/page.html"
31+ },
32+ }
33+
34+ if got := o.buildURL("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
35+ t.Fatalf("buildURL(unknown bang) = %q, want search fallback", got)
36+ }
37+}
A internal/ui/component/omnibox_favorite_toggle_test.go +153 -0
@@ -0,0 +1,153 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestResolveFavoriteToggleTarget(t *testing.T) {
10+ suggestions := []Suggestion{
11+ {URL: "https://example.com", Title: "Example"},
12+ {URL: "https://favorite.example.com", Title: "Favorite", IsFavorite: true},
13+ }
14+ favorites := []Favorite{
15+ {ID: 42, URL: "https://saved.example.com", Title: "Saved"},
16+ {ID: 0, URL: "https://invalid.example.com", Title: "Invalid"},
17+ }
18+
19+ tests := []struct {
20+ name string
21+ mode ViewMode
22+ index int
23+ suggestions []Suggestion
24+ favorites []Favorite
25+ wantTarget favoriteToggleTarget
26+ wantValidTarget bool
27+ }{
28+ {
29+ name: "history add target uses selected suggestion URL and title",
30+ mode: ViewModeHistory,
31+ index: 0,
32+ suggestions: suggestions,
33+ favorites: favorites,
34+ wantTarget: favoriteToggleTarget{
35+ url: "https://example.com",
36+ title: "Example",
37+ },
38+ wantValidTarget: true,
39+ },
40+ {
41+ name: "history remove target preserves prior favorite state",
42+ mode: ViewModeHistory,
43+ index: 1,
44+ suggestions: suggestions,
45+ favorites: favorites,
46+ wantTarget: favoriteToggleTarget{
47+ url: "https://favorite.example.com",
48+ title: "Favorite",
49+ wasFavorite: true,
50+ },
51+ wantValidTarget: true,
52+ },
53+ {
54+ name: "history invalid index has no target",
55+ mode: ViewModeHistory,
56+ index: 2,
57+ suggestions: suggestions,
58+ favorites: favorites,
59+ wantValidTarget: false,
60+ },
61+ {
62+ name: "history empty URL has no target",
63+ mode: ViewModeHistory,
64+ index: 0,
65+ suggestions: []Suggestion{{Title: "Missing URL"}},
66+ favorites: favorites,
67+ wantValidTarget: false,
68+ },
69+ {
70+ name: "favorites remove target uses favorite ID",
71+ mode: ViewModeFavorites,
72+ index: 0,
73+ suggestions: suggestions,
74+ favorites: favorites,
75+ wantTarget: favoriteToggleTarget{
76+ url: "https://saved.example.com",
77+ title: "Saved",
78+ favoriteID: 42,
79+ },
80+ wantValidTarget: true,
81+ },
82+ {
83+ name: "favorites invalid index has no target",
84+ mode: ViewModeFavorites,
85+ index: -1,
86+ suggestions: suggestions,
87+ favorites: favorites,
88+ wantValidTarget: false,
89+ },
90+ {
91+ name: "favorites zero ID has no target",
92+ mode: ViewModeFavorites,
93+ index: 1,
94+ suggestions: suggestions,
95+ favorites: favorites,
96+ wantValidTarget: false,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ gotTarget, gotValidTarget := resolveFavoriteToggleTarget(tt.mode, tt.index, tt.suggestions, tt.favorites)
103+ assert.Equal(t, tt.wantValidTarget, gotValidTarget)
104+ assert.Equal(t, tt.wantTarget, gotTarget)
105+ })
106+ }
107+}
108+
109+func TestResolveFavoriteToggleResultUpdate(t *testing.T) {
110+ suggestions := []Suggestion{{URL: "https://example.com"}}
111+
112+ tests := []struct {
113+ name string
114+ index int
115+ expectedURL string
116+ added bool
117+ want favoriteToggleResultUpdate
118+ }{
119+ {
120+ name: "matching URL updates selected suggestion favorite state",
121+ index: 0,
122+ expectedURL: "https://example.com",
123+ added: true,
124+ want: favoriteToggleResultUpdate{Apply: true, Index: 0, IsFavorite: true},
125+ },
126+ {
127+ name: "stale URL skips update",
128+ index: 0,
129+ expectedURL: "https://stale.example.com",
130+ added: true,
131+ want: favoriteToggleResultUpdate{},
132+ },
133+ {
134+ name: "out of range index skips update",
135+ index: 1,
136+ expectedURL: "https://example.com",
137+ added: true,
138+ want: favoriteToggleResultUpdate{},
139+ },
140+ }
141+
142+ for _, tt := range tests {
143+ t.Run(tt.name, func(t *testing.T) {
144+ got := resolveFavoriteToggleResultUpdate(suggestions, tt.index, tt.expectedURL, tt.added)
145+ assert.Equal(t, tt.want, got)
146+ })
147+ }
148+}
149+
150+func TestFavoriteToggleErrorMessage(t *testing.T) {
151+ assert.Equal(t, "Failed to add favorite", favoriteToggleErrorMessage(false))
152+ assert.Equal(t, "Failed to remove favorite", favoriteToggleErrorMessage(true))
153+}
M internal/ui/coordinator/content/lifecycle.go +1 -6
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/gtkutil"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"10 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
@@ -167,11 +166,7 @@ func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Wi
167 return nil166 return nil
168 }167 }
169 168
170- gtkWidget := gtkutil.WidgetFromNativePointer(ptr)169+ widget := c.widgetFactory.WrapNativeWidget(ptr)
171- if gtkWidget == nil {
172- return nil
173- }
174- widget := c.widgetFactory.WrapWidget(gtkWidget)
175 170
176 // Attach gesture handler for mouse button 8/9 navigation171 // Attach gesture handler for mouse button 8/9 navigation
177 if widget != nil {172 if widget != nil {
M internal/ui/coordinator/content/lifecycle_test.go +70 -0
@@ -348,6 +348,76 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
348 assert.Nil(t, c.GetWebView("popup-1"))348 assert.Nil(t, c.GetWebView("popup-1"))
349 }349 }
350 350
351+type nativeWebViewStub struct {
352+ port.WebView
353+ ptr uintptr
354+}
355+
356+func (s *nativeWebViewStub) NativeWidget() uintptr {
357+ return s.ptr
358+}
359+
360+func TestLifecycle_WrapWidget_ReturnsNilForNilWebView(t *testing.T) {
361+ t.Parallel()
362+
363+ factory := layoutmocks.NewMockWidgetFactory(t)
364+ c := newMinimalCoordinator()
365+ c.widgetFactory = factory
366+
367+ assert.Nil(t, c.WrapWidget(context.Background(), nil))
368+}
369+
370+func TestLifecycle_WrapWidget_ReturnsNilForMissingNativeProvider(t *testing.T) {
371+ t.Parallel()
372+
373+ wv := mocks.NewMockWebView(t)
374+ factory := layoutmocks.NewMockWidgetFactory(t)
375+ c := newMinimalCoordinator()
376+ c.widgetFactory = factory
377+
378+ assert.Nil(t, c.WrapWidget(context.Background(), wv))
379+}
380+
381+func TestLifecycle_WrapWidget_ReturnsNilForZeroNativePointer(t *testing.T) {
382+ t.Parallel()
383+
384+ base := mocks.NewMockWebView(t)
385+ wv := &nativeWebViewStub{WebView: base}
386+ factory := layoutmocks.NewMockWidgetFactory(t)
387+ c := newMinimalCoordinator()
388+ c.widgetFactory = factory
389+
390+ assert.Nil(t, c.WrapWidget(context.Background(), wv))
391+}
392+
393+func TestLifecycle_WrapWidget_ReturnsNilForNilWrappedWidget(t *testing.T) {
394+ t.Parallel()
395+
396+ base := mocks.NewMockWebView(t)
397+ wv := &nativeWebViewStub{WebView: base, ptr: 123}
398+ factory := layoutmocks.NewMockWidgetFactory(t)
399+ factory.EXPECT().WrapNativeWidget(uintptr(123)).Return(nil).Once()
400+ c := newMinimalCoordinator()
401+ c.widgetFactory = factory
402+
403+ assert.Nil(t, c.WrapWidget(context.Background(), wv))
404+}
405+
406+func TestLifecycle_WrapWidget_RequestsGtkWidgetForGestureSetup(t *testing.T) {
407+ t.Parallel()
408+
409+ base := mocks.NewMockWebView(t)
410+ wv := &nativeWebViewStub{WebView: base, ptr: 456}
411+ factory := layoutmocks.NewMockWidgetFactory(t)
412+ widget := layoutmocks.NewMockWidget(t)
413+ factory.EXPECT().WrapNativeWidget(uintptr(456)).Return(widget).Once()
414+ widget.EXPECT().GtkWidget().Return(nil).Once()
415+ c := newMinimalCoordinator()
416+ c.widgetFactory = factory
417+
418+ assert.Same(t, widget, c.WrapWidget(context.Background(), wv))
419+}
420+
351 type syncViewportContextKey struct{}421 type syncViewportContextKey struct{}
352 422
353 type syncViewportCapableStub struct {423 type syncViewportCapableStub struct {
M internal/ui/coordinator/tab_test.go +5 -5
@@ -26,7 +26,7 @@ func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
26 secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))26 secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27 27
28 gen := counterIDGen()28 gen := counterIDGen()
29- tabsUC := usecase.NewManageTabsUseCase(gen)29+ tabsUC := usecase.NewManageTabsUseCase(gen, nil)
30 30
31 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{31 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32 TabsUC: tabsUC,32 TabsUC: tabsUC,
@@ -124,7 +124,7 @@ func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
124 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))124 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125 125
126 gen := counterIDGen()126 gen := counterIDGen()
127- tabsUC := usecase.NewManageTabsUseCase(gen)127+ tabsUC := usecase.NewManageTabsUseCase(gen, nil)
128 128
129 // Create TabCoordinator without global tabs (target-driven).129 // Create TabCoordinator without global tabs (target-driven).
130 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{130 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
@@ -175,7 +175,7 @@ func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
175 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))175 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))176 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177 177
178- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})178+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
179 coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})179 coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180 180
181 secondTarget := TabTarget{Tabs: secondTabs}181 secondTarget := TabTarget{Tabs: secondTabs}
@@ -222,7 +222,7 @@ func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
222 secondTabs := entity.NewTabList()222 secondTabs := entity.NewTabList()
223 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))223 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224 224
225- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})225+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
226 226
227 secondTarget := TabTarget{Tabs: secondTabs}227 secondTarget := TabTarget{Tabs: secondTabs}
228 228
@@ -260,7 +260,7 @@ func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
260 targetTabs := entity.NewTabList()260 targetTabs := entity.NewTabList()
261 targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))261 targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262 262
263- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})263+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
264 264
265 target := TabTarget{Tabs: targetTabs}265 target := TabTarget{Tabs: targetTabs}
266 266
M internal/ui/coordinator/workspace.go +17 -101
@@ -75,16 +75,6 @@ type stackPaneContext struct {
75 originalTitle string75 originalTitle string
76 }76 }
77 77
78-type incrementalCloseContext struct {
79- parentNode *entity.PaneNode
80- siblingNode *entity.PaneNode
81- grandparentNode *entity.PaneNode
82- parentWidget layout.Widget
83- siblingIsStartChild bool
84- parentIsStartInGrand bool
85- precheckReason string
86-}
87-
88 const (78 const (
89 defaultResizeStepPercent = 0.0579 defaultResizeStepPercent = 0.05
90 defaultResizeMinPanePercent = 0.180 defaultResizeMinPanePercent = 0.1
@@ -173,12 +163,12 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
173 163
174 // Split splits the active pane in the given direction.164 // Split splits the active pane in the given direction.
175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {165 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
176- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL))166+ return c.splitWithInitialURL(ctx, direction, c.newPaneURL)
177 }167 }
178 168
179 // SplitWithURL splits the active pane in the given direction and loads initialURL.169 // SplitWithURL splits the active pane in the given direction and loads initialURL.
180 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {170 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
181- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL))171+ return c.splitWithInitialURL(ctx, direction, initialURL)
182 }172 }
183 173
184 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {174 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
@@ -1261,80 +1251,6 @@ func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
1261 return closeCtx1251 return closeCtx
1262 }1252 }
1263 1253
1264-func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
1265- var closeCtx incrementalCloseContext
1266- if closingPane == nil {
1267- return closeCtx, errors.New("closing pane missing")
1268- }
1269-
1270- parentNode := closingPane.Parent
1271- if parentNode == nil {
1272- return closeCtx, errors.New("closing pane has no parent")
1273- }
1274-
1275- closeCtx.parentNode = parentNode
1276- closeCtx.grandparentNode = parentNode.Parent
1277-
1278- if parentNode.IsStacked {
1279- return closeCtx, nil
1280- }
1281-
1282- if !parentNode.IsSplit() {
1283- return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
1284- }
1285-
1286- if len(parentNode.Children) != 2 {
1287- return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
1288- }
1289-
1290- leftChild := parentNode.Left()
1291- rightChild := parentNode.Right()
1292- if leftChild == closingPane && rightChild == nil {
1293- return closeCtx, errors.New("sibling missing")
1294- }
1295- if rightChild == closingPane && leftChild == nil {
1296- return closeCtx, errors.New("sibling missing")
1297- }
1298- if leftChild == nil || rightChild == nil {
1299- return closeCtx, errors.New("split parent has nil child")
1300- }
1301-
1302- switch {
1303- case leftChild == closingPane:
1304- closeCtx.siblingNode = rightChild
1305- closeCtx.siblingIsStartChild = false
1306- case rightChild == closingPane:
1307- closeCtx.siblingNode = leftChild
1308- closeCtx.siblingIsStartChild = true
1309- default:
1310- return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
1311- }
1312-
1313- if closeCtx.siblingNode == nil {
1314- return closeCtx, errors.New("sibling missing")
1315- }
1316-
1317- if closeCtx.grandparentNode != nil {
1318- switch {
1319- case closeCtx.grandparentNode.Left() == parentNode:
1320- closeCtx.parentIsStartInGrand = true
1321- case closeCtx.grandparentNode.Right() == parentNode:
1322- closeCtx.parentIsStartInGrand = false
1323- default:
1324- return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
1325- }
1326- }
1327-
1328- return closeCtx, nil
1329-}
1330-
1331-func paneNodeID(node *entity.PaneNode) string {
1332- if node == nil {
1333- return nilString
1334- }
1335- return node.ID
1336-}
1337-
1338 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.1254 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1339 func (c *WorkspaceCoordinator) doIncrementalStackClose(1255 func (c *WorkspaceCoordinator) doIncrementalStackClose(
1340 ctx context.Context,1256 ctx context.Context,
@@ -1544,24 +1460,23 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1544 return nil1460 return nil
1545 }1461 }
1546 1462
1547- // Create new pane entity1463+ // Determine if we need to create a new stack or add to existing.
1548- newPaneID := entity.PaneID(c.generateID())
1549- newPane := entity.NewPane(newPaneID)
1550- newPane.URI = domainurl.Normalize(c.newPaneURL)
1551- newPane.Title = defaultPaneTitle
1552-
1553- // Determine if we need to create a new stack or add to existing
1554 var stackNode *entity.PaneNode1464 var stackNode *entity.PaneNode
1465+ var newPane *entity.Pane
1466+ var newPaneID entity.PaneID
1555 var needsFirstPaneTitleUpdate bool1467 var needsFirstPaneTitleUpdate bool
1556 1468
1557 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {1469 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1558 // Already in a stack - use AddToStack use case1470 // Already in a stack - use AddToStack use case
1559 stackNode = stackCtx.activeNode.Parent1471 stackNode = stackCtx.activeNode.Parent
1560- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)1472+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1561 if err != nil {1473 if err != nil {
1562 log.Error().Err(err).Msg("failed to add pane to stack via use case")1474 log.Error().Err(err).Msg("failed to add pane to stack via use case")
1563 return err1475 return err
1564 }1476 }
1477+ newPane = output.NewPaneNode.Pane
1478+ newPaneID = newPane.ID
1479+ newPane.Title = defaultPaneTitle
1565 log.Debug().1480 log.Debug().
1566 Int("stack_size", len(stackNode.Children)).1481 Int("stack_size", len(stackNode.Children)).
1567 Int("insert_index", output.StackIndex).1482 Int("insert_index", output.StackIndex).
@@ -1569,28 +1484,29 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1569 } else if stackCtx.activeNode.IsStacked {1484 } else if stackCtx.activeNode.IsStacked {
1570 // Active node is already a stack container - add to it1485 // Active node is already a stack container - add to it
1571 stackNode = stackCtx.activeNode1486 stackNode = stackCtx.activeNode
1572- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)1487+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1573 if err != nil {1488 if err != nil {
1574 log.Error().Err(err).Msg("failed to add pane to stack via use case")1489 log.Error().Err(err).Msg("failed to add pane to stack via use case")
1575 return err1490 return err
1576 }1491 }
1492+ newPane = output.NewPaneNode.Pane
1493+ newPaneID = newPane.ID
1494+ newPane.Title = defaultPaneTitle
1577 log.Debug().1495 log.Debug().
1578 Int("stack_size", len(stackNode.Children)).1496 Int("stack_size", len(stackNode.Children)).
1579 Int("insert_index", output.StackIndex).1497 Int("insert_index", output.StackIndex).
1580 Msg("added to stack container via use case")1498 Msg("added to stack container via use case")
1581 } else {1499 } else {
1582- // Need to create a new stack - use CreateStack use case1500+ // Need to create a new stack - use CreateStack use case.
1583- // But CreateStack creates its own pane, so we need a different approach1501+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL)
1584- // Convert to stack first, then the new pane is already created
1585- output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1586 if err != nil {1502 if err != nil {
1587 log.Error().Err(err).Msg("failed to create stack via use case")1503 log.Error().Err(err).Msg("failed to create stack via use case")
1588 return err1504 return err
1589 }1505 }
1590 stackNode = output.StackNode1506 stackNode = output.StackNode
1591- // CreateStack creates its own new pane, use that instead
1592 newPane = output.NewPane1507 newPane = output.NewPane
1593 newPaneID = newPane.ID1508 newPaneID = newPane.ID
1509+ newPane.Title = defaultPaneTitle
1594 needsFirstPaneTitleUpdate = true1510 needsFirstPaneTitleUpdate = true
1595 1511
1596 // Update the original pane's title in the domain1512 // Update the original pane's title in the domain
@@ -2119,7 +2035,7 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input con
2119 stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)2035 stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
2120 2036
2121 // Add popup pane to stack using use case2037 // Add popup pane to stack using use case
2122- if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {2038+ if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane, ""); err != nil {
2123 // Rollback stack conversion if we created a new stack2039 // Rollback stack conversion if we created a new stack
2124 conversionInfo.revert()2040 conversionInfo.revert()
2125 return fmt.Errorf("failed to add popup to stack: %w", err)2041 return fmt.Errorf("failed to add popup to stack: %w", err)
A internal/ui/coordinator/workspace_close_context.go +93 -0
@@ -0,0 +1,93 @@
1+package coordinator
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+type incrementalCloseContext struct {
12+ parentNode *entity.PaneNode
13+ siblingNode *entity.PaneNode
14+ grandparentNode *entity.PaneNode
15+ parentWidget layout.Widget
16+ siblingIsStartChild bool
17+ parentIsStartInGrand bool
18+ precheckReason string
19+}
20+
21+func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
22+ var closeCtx incrementalCloseContext
23+ if closingPane == nil {
24+ return closeCtx, errors.New("closing pane missing")
25+ }
26+
27+ parentNode := closingPane.Parent
28+ if parentNode == nil {
29+ return closeCtx, errors.New("closing pane has no parent")
30+ }
31+
32+ closeCtx.parentNode = parentNode
33+ closeCtx.grandparentNode = parentNode.Parent
34+
35+ if parentNode.IsStacked {
36+ return closeCtx, nil
37+ }
38+
39+ if !parentNode.IsSplit() {
40+ return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
41+ }
42+
43+ if len(parentNode.Children) != 2 {
44+ return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
45+ }
46+
47+ leftChild := parentNode.Left()
48+ rightChild := parentNode.Right()
49+ if leftChild == closingPane && rightChild == nil {
50+ return closeCtx, errors.New("sibling missing")
51+ }
52+ if rightChild == closingPane && leftChild == nil {
53+ return closeCtx, errors.New("sibling missing")
54+ }
55+ if leftChild == nil || rightChild == nil {
56+ return closeCtx, errors.New("split parent has nil child")
57+ }
58+
59+ switch {
60+ case leftChild == closingPane:
61+ closeCtx.siblingNode = rightChild
62+ closeCtx.siblingIsStartChild = false
63+ case rightChild == closingPane:
64+ closeCtx.siblingNode = leftChild
65+ closeCtx.siblingIsStartChild = true
66+ default:
67+ return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
68+ }
69+
70+ if closeCtx.siblingNode == nil {
71+ return closeCtx, errors.New("sibling missing")
72+ }
73+
74+ if closeCtx.grandparentNode != nil {
75+ switch {
76+ case closeCtx.grandparentNode.Left() == parentNode:
77+ closeCtx.parentIsStartInGrand = true
78+ case closeCtx.grandparentNode.Right() == parentNode:
79+ closeCtx.parentIsStartInGrand = false
80+ default:
81+ return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
82+ }
83+ }
84+
85+ return closeCtx, nil
86+}
87+
88+func paneNodeID(node *entity.PaneNode) string {
89+ if node == nil {
90+ return nilString
91+ }
92+ return node.ID
93+}
M internal/ui/coordinator/workspace_close_test.go +57 -0
@@ -59,6 +59,47 @@ func TestDeriveIncrementalCloseTreeContext_ValidSplit(t *testing.T) {
59 }59 }
60 }60 }
61 61
62+func TestDeriveIncrementalCloseTreeContext_InvalidPreconditions(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ closingPane *entity.PaneNode
66+ wantText string
67+ }{
68+ {
69+ name: "nil closing pane",
70+ closingPane: nil,
71+ wantText: "closing pane missing",
72+ },
73+ {
74+ name: "closing pane has no parent",
75+ closingPane: testLeafNode("closing"),
76+ wantText: "closing pane has no parent",
77+ },
78+ {
79+ name: "parent is not split",
80+ closingPane: func() *entity.PaneNode {
81+ closing := testLeafNode("closing")
82+ parent := &entity.PaneNode{ID: "parent", Children: []*entity.PaneNode{closing}}
83+ closing.Parent = parent
84+ return closing
85+ }(),
86+ wantText: "parent node is not split",
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ _, err := deriveIncrementalCloseTreeContext(tt.closingPane)
93+ if err == nil {
94+ t.Fatalf("expected error")
95+ }
96+ if !strings.Contains(err.Error(), tt.wantText) {
97+ t.Fatalf("expected %q error, got: %v", tt.wantText, err)
98+ }
99+ })
100+ }
101+}
102+
62 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {103 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {
63 closing := testLeafNode("closing")104 closing := testLeafNode("closing")
64 parent := testSplitNode("parent", closing, nil)105 parent := testSplitNode("parent", closing, nil)
@@ -92,6 +133,22 @@ func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *te
92 }133 }
93 }134 }
94 135
136+func TestDeriveIncrementalCloseTreeContext_ParentMissingFromGrandparent(t *testing.T) {
137+ closing := testLeafNode("closing")
138+ sibling := testLeafNode("sibling")
139+ parent := testSplitNode("parent", closing, sibling)
140+ grand := testSplitNode("grand", testLeafNode("other-left"), testLeafNode("other-right"))
141+ parent.Parent = grand
142+
143+ _, err := deriveIncrementalCloseTreeContext(closing)
144+ if err == nil {
145+ t.Fatalf("expected invariant error")
146+ }
147+ if !strings.Contains(err.Error(), "parent not found under grandparent") {
148+ t.Fatalf("unexpected error: %v", err)
149+ }
150+}
151+
95 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {152 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {
96 closing := testLeafNode("closing")153 closing := testLeafNode("closing")
97 testSplitNode("parent", closing, nil)154 testSplitNode("parent", closing, nil)
M internal/ui/coordinator/workspace_systemview_test.go +3 -3
@@ -19,7 +19,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightSplitsWithTargetURL(t *testin
19 id := ids[idx]19 id := ids[idx]
20 idx++20 idx++
21 return id21 return id
22- })22+ }, nil)
23 23
24 initialPane := entity.NewPane("pane-1")24 initialPane := entity.NewPane("pane-1")
25 initialPane.URI = "https://example.com"25 initialPane.URI = "https://example.com"
@@ -56,7 +56,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
56 PanesUC: usecase.NewManagePanesUseCase(func() string {56 PanesUC: usecase.NewManagePanesUseCase(func() string {
57 t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")57 t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")
58 return "unused"58 return "unused"
59- }),59+ }, nil),
60 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {60 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
61 return ws, nil61 return ws, nil
62 },62 },
@@ -71,7 +71,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
71 71
72 func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) {72 func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) {
73 ctx := context.Background()73 ctx := context.Background()
74- uc := usecase.NewManagePanesUseCase(func() string { return "unused" })74+ uc := usecase.NewManagePanesUseCase(func() string { return "unused" }, nil)
75 left := testLeafNode("pane-1")75 left := testLeafNode("pane-1")
76 left.Pane.URI = "https://example.com"76 left.Pane.URI = "https://example.com"
77 right := testLeafNode("pane-2")77 right := testLeafNode("pane-2")
M internal/ui/deps.go +2 -0
@@ -89,6 +89,8 @@ type Dependencies struct {
89 SessionSpawner port.SessionSpawner89 SessionSpawner port.SessionSpawner
90 // FileSystem provides file operations (e.g., for download deduplication).90 // FileSystem provides file operations (e.g., for download deduplication).
91 FileSystem port.FileSystem91 FileSystem port.FileSystem
92+ // LocalPathResolver resolves user-entered local filesystem paths for navigation.
93+ LocalPathResolver port.LocalPathResolver
92 94
93 // Update management95 // Update management
94 CheckUpdateUC *usecase.CheckUpdateUseCase96 CheckUpdateUC *usecase.CheckUpdateUseCase
M internal/ui/layout/gtk_widgets.go +7 -0
@@ -827,3 +827,10 @@ func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget {
827 }827 }
828 return &gtkWidget{inner: w}828 return &gtkWidget{inner: w}
829 }829 }
830+
831+func (f *GtkWidgetFactory) WrapNativeWidget(ptr uintptr) Widget {
832+ if ptr == 0 {
833+ return nil
834+ }
835+ return f.WrapWidget(gtk.WidgetNewFromInternalPtr(ptr))
836+}
M internal/ui/layout/mocks/widgets.go +53 -0
@@ -13898,6 +13898,59 @@ func (_c *MockWidgetFactory_NewSpinner_Call) RunAndReturn(run func() layout.Spin
13898 return _c13898 return _c
13899 }13899 }
13900 13900
13901+// WrapNativeWidget provides a mock function for the type MockWidgetFactory
13902+func (_mock *MockWidgetFactory) WrapNativeWidget(ptr uintptr) layout.Widget {
13903+ ret := _mock.Called(ptr)
13904+
13905+ if len(ret) == 0 {
13906+ panic("no return value specified for WrapNativeWidget")
13907+ }
13908+
13909+ var r0 layout.Widget
13910+ if returnFunc, ok := ret.Get(0).(func(uintptr) layout.Widget); ok {
13911+ r0 = returnFunc(ptr)
13912+ } else {
13913+ if ret.Get(0) != nil {
13914+ r0 = ret.Get(0).(layout.Widget)
13915+ }
13916+ }
13917+ return r0
13918+}
13919+
13920+// MockWidgetFactory_WrapNativeWidget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WrapNativeWidget'
13921+type MockWidgetFactory_WrapNativeWidget_Call struct {
13922+ *mock.Call
13923+}
13924+
13925+// WrapNativeWidget is a helper method to define mock.On call
13926+// - ptr uintptr
13927+func (_e *MockWidgetFactory_Expecter) WrapNativeWidget(ptr any) *MockWidgetFactory_WrapNativeWidget_Call {
13928+ return &MockWidgetFactory_WrapNativeWidget_Call{Call: _e.mock.On("WrapNativeWidget", ptr)}
13929+}
13930+
13931+func (_c *MockWidgetFactory_WrapNativeWidget_Call) Run(run func(ptr uintptr)) *MockWidgetFactory_WrapNativeWidget_Call {
13932+ _c.Call.Run(func(args mock.Arguments) {
13933+ var arg0 uintptr
13934+ if args[0] != nil {
13935+ arg0 = args[0].(uintptr)
13936+ }
13937+ run(
13938+ arg0,
13939+ )
13940+ })
13941+ return _c
13942+}
13943+
13944+func (_c *MockWidgetFactory_WrapNativeWidget_Call) Return(widget layout.Widget) *MockWidgetFactory_WrapNativeWidget_Call {
13945+ _c.Call.Return(widget)
13946+ return _c
13947+}
13948+
13949+func (_c *MockWidgetFactory_WrapNativeWidget_Call) RunAndReturn(run func(ptr uintptr) layout.Widget) *MockWidgetFactory_WrapNativeWidget_Call {
13950+ _c.Call.Return(run)
13951+ return _c
13952+}
13953+
13901 // WrapWidget provides a mock function for the type MockWidgetFactory13954 // WrapWidget provides a mock function for the type MockWidgetFactory
13902 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {13955 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {
13903 ret := _mock.Called(w)13956 ret := _mock.Called(w)
M internal/ui/layout/widgets.go +1 -0
@@ -241,6 +241,7 @@ type WidgetFactory interface {
241 241
242 // Wrap existing GTK widget242 // Wrap existing GTK widget
243 WrapWidget(w *gtk.Widget) Widget243 WrapWidget(w *gtk.Widget) Widget
244+ WrapNativeWidget(ptr uintptr) Widget
244 }245 }
245 246
246 // PaneRenderer creates pane view widgets from domain entities.247 // PaneRenderer creates pane view widgets from domain entities.
M internal/ui/standalone_runtime_test.go +24 -0
@@ -3,11 +3,35 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ stdurl "net/url"
7+ "path/filepath"
6 "testing"8 "testing"
7 9
8 urlutil "github.com/bnema/dumber/internal/domain/url"10 urlutil "github.com/bnema/dumber/internal/domain/url"
9 )11 )
10 12
13+type standaloneTestLocalPathResolver struct {
14+ paths map[string]string
15+}
16+
17+func (r standaloneTestLocalPathResolver) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
18+ absPath, ok := r.paths[input]
19+ return absPath, ok, nil
20+}
21+
22+func TestNewStandaloneOmniboxRuntimeUsesExplicitLocalPathResolver(t *testing.T) {
23+ absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
24+ runtime := NewStandaloneOmniboxRuntime(context.Background(), &Dependencies{
25+ LocalPathResolver: standaloneTestLocalPathResolver{paths: map[string]string{"page.html": absFile}},
26+ }, nil)
27+
28+ got := runtime.OmniboxCfg.NormalizeNavigationURL(context.Background(), "page.html")
29+ want := (&stdurl.URL{Scheme: "file", Path: absFile}).String()
30+ if got != want {
31+ t.Fatalf("NormalizeNavigationURL(page.html) = %q, want %q", got, want)
32+ }
33+}
34+
11 func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) {35 func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) {
12 browserURL := ""36 browserURL := ""
13 externalURL := ""37 externalURL := ""

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