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:
5555 - name: Install dependencies
5656 run: |
5757 pacman -Syu --noconfirm
58- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
58+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli
5959 - uses: actions/checkout@v7
6060 - name: Allow git commands in container checkout
6161 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -99,7 +99,7 @@ jobs:
9999 - name: Install dependencies
100100 run: |
101101 pacman -Syu --noconfirm
102- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
102+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus
103103 - uses: actions/checkout@v7
104104 - name: Set up pinned Go toolchain
105105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
M .mockery.yaml +1 -0
@@ -107,6 +107,7 @@ packages:
107107 FaviconRepository: {}
108108 FaviconService: {}
109109 FileSystem: {}
110+ LocalPathResolver: {}
110111 FilterManager: {}
111112 Focusable: {}
112113 FindController: {}
M README.md +5 -3
@@ -109,7 +109,7 @@ The floating pane is a temporary browser pane that can be toggled without changi
109109 - `Alt+F` toggles the floating pane and preserves its state while hidden.
110110 - `Ctrl+W` closes the active pane; if the floating pane is active, it is fully released for a fresh next open.
111111 - 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.
113113
114114 See the [floating pane reference](https://dumber.bnema.dev/docs/reference/floating-pane) for setup and behavior details.
115115
@@ -200,7 +200,7 @@ Bug reports and reproducible Wayland/backend issues are welcome.
200200
201201 ## Development
202202
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.
204204
205205 Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
206206
@@ -209,7 +209,9 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
209209 **Prerequisites:**
210210
211211 - Go 1.26+
212-- WebKitGTK 6.0 and GTK4 development packages
212+- GTK4 development packages
213+- CEF runtime (default backend)
214+- WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
213215 - Brotli for compressed systemviews assets
214216
215217 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 {
790790 updateChecker := updater.NewGitHubChecker()
791791 updateDownloader := updater.NewGitHubDownloader()
792792 updateApplier := updater.NewApplier(stateDir)
793+ localPaths := filesystem.New()
793794
794795 // Permission use case will be initialized later with dialog presenter
795796 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
@@ -800,8 +801,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
800801 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)
801802
802803 return &useCases{
803- tabs: usecase.NewManageTabsUseCase(idGenerator),
804- panes: usecase.NewManagePanesUseCase(idGenerator),
804+ tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths),
805+ panes: usecase.NewManagePanesUseCase(idGenerator, localPaths),
805806 history: historyUC,
806807 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
807808 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
@@ -882,6 +883,7 @@ func buildUIDependencies(
882883 focusProvider := textinput.NewFocusProvider()
883884 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
884885 runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager())
886+ localPaths := filesystem.New()
885887
886888 uiDeps := &ui.Dependencies{
887889 Ctx: ctx,
@@ -935,7 +937,8 @@ func buildUIDependencies(
935937 CheckUpdateUC: uc.checkUpdate,
936938 ApplyUpdateUC: uc.applyUpdate,
937939 SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
938- FileSystem: filesystem.New(),
940+ FileSystem: localPaths,
941+ LocalPathResolver: localPaths,
939942 AccentFocusProvider: focusProvider,
940943 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
941944 return textinput.NewGTKEntryTarget(entry)
M docs/cli/index.md +1 -1
@@ -141,7 +141,7 @@ dumber doctor [flags]
141141
142142 | Flag | Description |
143143 |------|-------------|
144-| `--runtime` | Only run runtime checks (GTK4/WebKitGTK) |
144+| `--runtime` | Only run runtime checks for GTK and WebKit fallback libraries |
145145 | `--media` | Only run media checks (GStreamer/VA-API) |
146146
147147 ### setup
M docs/config/index.md +122 -101
@@ -245,41 +245,82 @@ When Noctalia rewrites the watched file, Dumber reapplies the same resolved them
245245 | Key | Type | Default | Description |
246246 |-----|------|---------|-------------|
247247 | `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+
248260
249261 ## Rendering, UI Scale & Zoom
250262
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+
251265 | Key | Type | Default | Valid Values | Description |
252266 |-----|------|---------|--------------|-------------|
253-| `rendering.mode` | string | `"gpu"` | `auto`, `gpu`, `cpu` | WebKit hardware acceleration policy |
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. |
267+| `engine.type` | string | `"cef"` | `cef`, `webkit` | Browser engine selection; WebKitGTK is the fallback option |
265268 | `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`) |
267-| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default |
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 |
269-| `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 |
271-| `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` |
273-| `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | Maximum absolute scroll delta after scaling; 0 disables clamping |
269+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling when `windowless_frame_rate = 0` |
270+| `engine.cef.windowless_frame_rate` | int32 | `0` | `>= 0` | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled |
271+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | `>= 0` | Hard cap for adaptive CEF OSR FPS; 0 uses the built-in cap |
272+| `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | `> 0` | Mouse wheel scroll sensitivity multiplier |
273+| `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | `> 0` | Precise/surface scroll sensitivity multiplier for touchpads and high-resolution wheels |
274+| `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | `> 0` | Horizontal scroll sensitivity multiplier |
275+| `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads |
276+| `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping |
274277 | `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 |
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 |
277-| `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
278+| `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation |
279+| `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes |
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%) |
278295
279296 `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.
280297
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.
282-| `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
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.
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 |
283324
284325 ## Workspace Configuration
285326
@@ -548,21 +589,14 @@ max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup
548589 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
549590 | `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
550591 | `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) |
552-| `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |
553-| `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |
592+
593+WebKit fallback GStreamer tuning is configured under `engine.webkit.force_vsync`, `engine.webkit.gl_rendering_mode`, and `engine.webkit.gstreamer_debug_level`.
554594
555595 **Hardware decoding modes:**
556596 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000
557597 - `force`: Hardware only - fails if unavailable
558598 - `disable`: Software only - higher CPU usage
559599
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-
566600 **GPU auto-detection:**
567601 Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal VA-API driver settings:
568602 - **AMD**: Uses `radeonsi` driver
@@ -570,28 +604,25 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
570604 - **NVIDIA**: Uses `nvidia` driver with EGL platform
571605
572606 **Example:**
607+
573608 ```toml
574609 [media]
575610 hardware_decoding = "auto" # HW preferred, SW fallback
576611 prefer_av1 = false # Let site choose codec
577612 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
581613 ```
582614
583-**Diagnostic mode toggle path:**
615+**WebKit fallback GStreamer diagnostics:**
584616
585617 ```toml
586-# ~/.config/dumber/config.toml
587-[media]
588-show_diagnostics = true
618+[engine.webkit]
589619 gstreamer_debug_level = 3
590620 ```
591621
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.
593623
594624 **Diagnostics CLI:**
625+
595626 ```bash
596627 # Check GStreamer plugins and VA-API status
597628 dumber doctor --media
@@ -601,17 +632,22 @@ dumber doctor --media
601632
602633 | Key | Type | Default | Description |
603634 |-----|------|---------|-------------|
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`) |
605637
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:
607639 - `PKG_CONFIG_PATH` (for version checks)
608640 - `LD_LIBRARY_PATH` (for runtime library loading)
609641 - `GI_TYPELIB_PATH` (GObject introspection)
610642 - `XDG_DATA_DIRS` (schemas/resources)
611643
612644 **Example:**
645+
613646 ```toml
614-[runtime]
647+[engine.cef]
648+cef_dir = "/opt/cef"
649+
650+[engine.webkit]
615651 prefix = "/opt/webkitgtk"
616652 ```
617653
@@ -624,6 +660,7 @@ prefix = "/opt/webkitgtk"
624660 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.
625661
626662 **Example:**
663+
627664 ```toml
628665 [clipboard]
629666 auto_copy_on_selection = true # Enabled by default
@@ -649,6 +686,7 @@ Notes:
649686 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
650687
651688 **Example:**
689+
652690 ```toml
653691 [update]
654692 enable_on_startup = true # Check for updates on startup
@@ -657,6 +695,7 @@ notify_on_new_settings = true # Show toast when config migration available
657695 ```
658696
659697 **CLI Commands:**
698+
660699 ```bash
661700 # Check config status and available migrations
662701 dumber config status
@@ -670,89 +709,71 @@ dumber config migrate --yes
670709
671710 ## Performance Profiles
672711
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.
674713
675714 > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect.
676715
677716 | Key | Type | Default | Valid Values | Description |
678717 |-----|------|---------|--------------|-------------|
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 |
680721
681722 ### Profiles
682723
683724 | Profile | Description | Use Case |
684725 |---------|-------------|----------|
685-| `default` | No tuning, uses WebKit defaults | Compatibility baseline, troubleshooting |
726+| `default` | No tuning, uses engine defaults | Compatibility baseline, troubleshooting |
686727 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
728+| `balanced` | Moderate tuning | General use |
687729 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
688730 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |
689731
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-
702732 **Example:**
703-```toml
704-[performance]
705-profile = "default" # WebKit baseline default
706733
707-# For WebKit baseline behavior:
708-# profile = "default"
709-
710-# For heavy pages (GitHub PRs, complex web apps):
711-# profile = "max"
712-
713-# For manual control:
714-# profile = "custom"
734+```toml
735+[engine]
736+profile = "default"
737+pool_prewarm_count = 4
738+zoom_cache_size = 256
715739 ```
716740
717-### Custom Profile Settings
741+### WebKit fallback custom profile settings
718742
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:
720744
721745 | Key | Type | Default | Description |
722746 |-----|------|---------|-------------|
723-| `performance.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) |
725-| `performance.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) |
727-| `performance.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) |
729-| `performance.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 |
731-| `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |
732-| `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |
733-| `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
734-| `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup |
735-| `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache |
736-
737-**Custom profile example:**
747+| `engine.webkit.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) |
748+| `engine.webkit.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) |
749+| `engine.webkit.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering |
750+| `engine.webkit.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) |
751+| `engine.webkit.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) |
752+| `engine.webkit.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) |
753+| `engine.webkit.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) |
754+| `engine.webkit.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB |
755+| `engine.webkit.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |
756+| `engine.webkit.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |
757+| `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
758+
759+**Custom WebKit fallback example:**
760+
738761 ```toml
739-[performance]
762+[engine]
763+type = "webkit"
740764 profile = "custom"
765+pool_prewarm_count = 6
766+zoom_cache_size = 256
741767
742-# Skia threading - tune for your CPU
768+[engine.webkit]
743769 skia_cpu_painting_threads = 4
744770 skia_gpu_painting_threads = 2
745-
746-# Web process memory pressure
747771 web_process_memory_limit_mb = 1024
748772 web_process_memory_conservative_threshold = 0.4
749773 web_process_memory_strict_threshold = 0.6
750-
751-# WebView pool
752-webview_pool_prewarm_count = 6
753774 ```
754775
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"`.
756777
757778 ## Downloads
758779
@@ -779,14 +800,14 @@ All config values can be overridden via environment variables with the prefix `D
779800 # Database
780801 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
781802
782-# Rendering (examples)
803+# Engine/rendering (examples)
804+DUMBER_ENGINE_TYPE=cef
783805 # CEF defaults to the GPU-first Vulkan DMABUF stack.
784806 # Use EGL/OpenGL for driver compatibility:
785807 # DUMBER_ENGINE_CEF_RENDER_STACK=egl
786-DUMBER_RENDERING_MODE=cpu
787-# WebKit-specific rendering overrides:
788-# DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
789-# DUMBER_RENDERING_GSK_RENDERER=opengl
808+# WebKit fallback rendering overrides:
809+# DUMBER_ENGINE_WEBKIT_DISABLE_DMABUF_RENDERER=true
810+# DUMBER_ENGINE_WEBKIT_GSK_RENDERER=opengl
790811 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5
791812
792813 # Logging
M docs/getting-started.md +2 -2
@@ -39,7 +39,7 @@ Press `Ctrl+L` to open the omnibox for:
3939
4040 - Press `Alt+F` to toggle the floating pane.
4141 - 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.
4343
4444 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.
4545
@@ -70,7 +70,7 @@ Dumber includes a built-in permissions system for camera, microphone, and screen
7070 - **Privacy-focused** - All permissions stored locally
7171 - **Camera & Microphone** - Fully working on Wayland/PipeWire
7272
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.
7474
7575 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.
7676
M docs/index.md +3 -3
@@ -1,6 +1,6 @@
11 # Dumber Documentation
22
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.
44
55 ## What is Dumber?
66
@@ -41,5 +41,5 @@ A browser designed for Wayland compositors that works like your favorite termina
4141 ## Requirements
4242
4343 - Linux with Wayland (Sway, Hyprland, River, Niri, etc.)
44-- GTK4 and WebKitGTK 2.42+
45-- GStreamer (for media playback)
44+- GTK4, CEF for the default backend, and WebKitGTK for the fallback backend
45+- GStreamer (for WebKit media playback and diagnostics)
M docs/installation.md +3 -2
@@ -51,8 +51,9 @@ make build
5151
5252 - Go 1.26+
5353 - GTK4 development libraries
54-- WebKitGTK 6.0 development libraries
55-- GStreamer development libraries
54+- CEF runtime for the default backend
55+- WebKitGTK 6.0 development/runtime libraries for the fallback backend and runtime checks
56+- GStreamer development libraries for WebKit media playback and diagnostics
5657 - Brotli for compressed systemviews assets
5758
5859 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 @@
3131 | `appearance.sans_font` | string | `Fira Sans` | |
3232 | `appearance.serif_font` | string | `Fira Sans` | |
3333 | `appearance.monospace_font` | string | `Fira Code` | |
34+| `appearance.gtk_font` | string | `Adwaita Sans` | |
3435 | `appearance.default_font_size` | int | `16` | |
3536 | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` |
3637 | `appearance.external_theme.enabled` | bool | `false` | |
@@ -52,18 +53,13 @@
5253 | `appearance.dark_palette.accent` | string | `#a8a8a8` | |
5354 | `appearance.dark_palette.border` | string | `#363636` | |
5455 | `debug.enable_devtools` | bool | `true` | |
55-| `rendering.mode` | string | `gpu` | `auto`, `gpu`, `cpu` |
56-| `rendering.disable_dmabuf_renderer` | bool | `false` | |
57-| `rendering.force_compositing_mode` | bool | `false` | |
58-| `rendering.disable_compositing_mode` | bool | `false` | |
59-| `rendering.gsk_renderer` | string | `auto` | `auto`, `opengl`, `vulkan`, `cairo` |
60-| `rendering.disable_mipmaps` | bool | `false` | |
61-| `rendering.prefer_gl` | bool | `false` | |
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` |
56+| `engine.cef.log_file` | string | `` | CEF runtime log path |
57+| `engine.cef.log_severity` | int32 | `0` | `0`, `1`, `2`, `3`, `4`, `99` |
58+| `engine.cef.trace_handlers` | bool | `false` | |
59+| `engine.cef.enable_audio_handler` | bool | `true` | experimental |
60+| `engine.type` | string | `cef` | `cef`, `webkit` (CEF default; WebKitGTK fallback) |
61+| `engine.cookie_policy` | string | `always` | `always`, `no_third_party`, `never` |
62+| `engine.webkit.itp_enabled` | bool | `true` | WebKit fallback only |
6763 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
6864 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
6965 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
@@ -74,24 +70,40 @@
7470 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |
7571 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |
7672 | `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 |
7874 | `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) |
7988 | `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`.
8489 | `default_webpage_zoom` | float | `1.2` | > 0 |
90+| `sidebar_width` | int | `320` | `0` or `280-380` |
8591 | `workspace.new_pane_url` | string | `about:blank` | |
8692 | `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` | |
8795 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
8896 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
97+| `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
8998 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |
9099 | `workspace.tab_mode.timeout_ms` | int | `3000` | |
100+| `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
91101 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
92102 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
103+| `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
93104 | `workspace.resize_mode.step_percent` | float | `5.0` | |
94105 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
106+| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings |
95107 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
96108 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
97109 | `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
121133 | `session.max_exited_session_age_days` | int | `7` | |
122134 | `session.session_mode.activation_shortcut` | string | `ctrl+o` | |
123135 | `session.session_mode.timeout_ms` | int | `3000` | |
136+| `session.session_mode.actions.<action>` | []string | see defaults | session mode key mappings |
124137 | `media.hardware_decoding` | string | `auto` | `auto`, `force`, `disable` |
125138 | `media.prefer_av1` | bool | `false` | |
126139 | `media.show_diagnostics` | bool | `false` | |
127-| `media.force_vsync` | bool | `false` | |
128-| `media.gl_rendering_mode` | string | `auto` | `auto`, `gles2`, `gl3`, `none` |
129-| `media.gstreamer_debug_level` | int | `0` | 0-5 |
130-| `runtime.prefix` | string | `` | |
140+| `engine.cef.cef_dir` | string | `` | CEF runtime directory |
141+| `engine.webkit.prefix` | string | `` | WebKitGTK fallback runtime prefix |
131142 | `clipboard.auto_copy_on_selection` | bool | `true` | |
132143 | `content_filtering.enabled` | bool | `true` | |
133144 | `content_filtering.auto_update` | bool | `true` | |
134145 | `update.enable_on_startup` | bool | `true` | |
135146 | `update.auto_download` | bool | `false` | |
136147 | `update.notify_on_new_settings` | bool | `true` | |
137-| `performance.profile` | string | `default` | `default`, `lite`, `max`, `custom` |
138-| `performance.skia_cpu_painting_threads` | int | `0` | |
139-| `performance.skia_gpu_painting_threads` | int | `-1` | |
140-| `performance.skia_enable_cpu_rendering` | bool | `false` | |
141-| `performance.web_process_memory_limit_mb` | int | `0` | |
142-| `performance.web_process_memory_poll_interval_sec` | float | `0` | |
143-| `performance.web_process_memory_conservative_threshold` | float | `0` | |
144-| `performance.web_process_memory_strict_threshold` | float | `0` | |
145-| `performance.network_process_memory_limit_mb` | int | `0` | |
146-| `performance.network_process_memory_poll_interval_sec` | float | `0` | |
147-| `performance.network_process_memory_conservative_threshold` | float | `0` | |
148-| `performance.network_process_memory_strict_threshold` | float | `0` | |
149-| `performance.webview_pool_prewarm_count` | int | `4` | |
150-| `performance.zoom_cache_size` | int | `256` | |
148+| `engine.profile` | string | `default` | `default`, `lite`, `balanced`, `max`, `custom` |
149+| `engine.webkit.skia_cpu_painting_threads` | int | `0` | >= 0 (WebKit fallback custom profile only) |
150+| `engine.webkit.skia_gpu_painting_threads` | int | `-1` | >= -1 (WebKit fallback custom profile only) |
151+| `engine.webkit.skia_enable_cpu_rendering` | bool | `false` | WebKit fallback custom profile only |
152+| `engine.webkit.web_process_memory_limit_mb` | int | `0` | >= 0 (WebKit fallback custom profile only) |
153+| `engine.webkit.web_process_memory_poll_interval_sec` | float | `0` | >= 0 (WebKit fallback custom profile only) |
154+| `engine.webkit.web_process_memory_conservative_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
155+| `engine.webkit.web_process_memory_strict_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
156+| `engine.webkit.network_process_memory_limit_mb` | int | `0` | >= 0 (WebKit fallback custom profile only) |
157+| `engine.webkit.network_process_memory_poll_interval_sec` | float | `0` | >= 0 (WebKit fallback custom profile only) |
158+| `engine.webkit.network_process_memory_conservative_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
159+| `engine.webkit.network_process_memory_strict_threshold` | float | `0` | 0-1 (WebKit fallback custom profile only) |
160+| `engine.pool_prewarm_count` | int | `4` | >= 0 |
161+| `engine.zoom_cache_size` | int | `256` | >= 0 |
151162 | `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
6161 sudo pacman -S nvidia-utils # includes Vulkan
6262 ```
6363
64-### GStreamer Plugins (for WebKit Media Playback)
64+### GStreamer Plugins (for WebKit fallback media playback)
6565
6666 ```bash
6767 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
7979
8080 ## Debian/Ubuntu
8181
82-### Core GTK4 and WebKit
82+### Core GTK4, CEF, and WebKit fallback
8383
8484 ```bash
8585 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).
8687 ```
8788
8889 ### VA-API
M docs/reference/floating-pane.md +1 -1
@@ -53,5 +53,5 @@ desc = "Open Google in floating pane"
5353 - The default install only binds `Alt+F` for floating pane toggle.
5454 - Profile shortcuts are opt-in and empty by default.
5555 - 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.
5757 - 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:
101101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
102102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
103103
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.
105105 If a shortcut does not trigger in Dumber, choose a different keybinding.
106106
107107 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:
1010
1111 - **Display Capture** (screen sharing) - The XDG Desktop Portal handles the UI
1212
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.
1414
1515 - **Device Enumeration** (listing microphones/cameras) - Low security risk
1616
M docs/reference/troubleshooting.md +21 -13
@@ -6,7 +6,7 @@ Run the doctor command to check your system:
66
77 ```bash
88 dumber doctor # Full check
9-dumber doctor --runtime # GTK4/WebKitGTK only
9+dumber doctor --runtime # GTK and WebKit fallback runtime checks only
1010 dumber doctor --media # GStreamer/VA-API only
1111 ```
1212
@@ -14,14 +14,13 @@ dumber doctor --media # GStreamer/VA-API only
1414
1515 ### Browser won't start
1616
17-**Symptoms:** Error about missing libraries or GTK/WebKitGTK version
17+**Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version.
1818
1919 **Solution:**
20-1. Run `dumber doctor --runtime`
21-2. Install missing dependencies:
22- - Arch: `pacman -S webkitgtk-6.0 gtk4`
23- - Fedora: `dnf install webkitgtk6.0 gtk4`
24- - Ubuntu: `apt install libwebkitgtk-6.0-4 libgtk-4-1`
20+1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`.
21+2. Match the fix to the backend in the error:
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+ - **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`.
2524
2625 ### Video playback issues
2726
@@ -29,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only
2928
3029 **Solution:**
3130 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:
3332
3433 ```toml
3534 # ~/.config/dumber/config.toml
3635 [media]
3736 show_diagnostics = true
37+
38+ [engine.webkit]
3839 gstreamer_debug_level = 3
3940 ```
4041
42+ For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback.
43+
4144 3. Check hardware decoding:
4245
4346 ```toml
@@ -50,21 +53,24 @@ dumber doctor --media # GStreamer/VA-API only
5053 - AMD: `libva-mesa-driver`
5154 - Intel: `intel-media-driver`
5255 - 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:
5457
5558 ```toml
5659 [media]
5760 show_diagnostics = false
61+
62+ [engine.webkit]
5863 gstreamer_debug_level = 0
5964 ```
6065
61-### Screen flickering on Wayland
66+### Screen flickering on Wayland with the WebKit fallback
6267
63-**Symptoms:** Flicker when scrolling or rendering
68+**Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"`
6469
6570 **Solution:**
71+
6672 ```toml
67-[rendering]
73+[engine.webkit]
6874 disable_dmabuf_renderer = true
6975 ```
7076
@@ -73,6 +79,7 @@ disable_dmabuf_renderer = true
7379 **Symptoms:** Missing characters, wrong font rendering
7480
7581 **Solution:**
82+
7683 ```toml
7784 [appearance]
7885 sans_font = "Your Preferred Font"
@@ -85,8 +92,9 @@ default_font_size = 16
8592 **Symptoms:** Browser uses too much RAM
8693
8794 **Solution:**
95+
8896 ```toml
89-[performance]
97+[engine]
9098 profile = "lite" # Reduces resource usage
9199 ```
92100
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 (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12+// ListAllSessionsLimit asks Execute to return every known session.
13+const ListAllSessionsLimit = -1
14+
1215 // ListSessionsUseCase handles listing sessions with their state information.
1316 type ListSessionsUseCase struct {
1417 sessionRepo repository.SessionRepository
@@ -36,7 +39,7 @@ type ListSessionsOutput struct {
3639 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
3740 log := logging.FromContext(ctx)
3841
39- if limit <= 0 {
42+ if limit == 0 {
4043 limit = 50
4144 }
4245
M internal/application/usecase/manage_panes.go +27 -10
@@ -8,8 +8,8 @@ import (
88 "sort"
99 "strings"
1010
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
12- domainurl "github.com/bnema/dumber/internal/domain/url"
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
@@ -72,15 +72,35 @@ const consumeOrExpelExpelCycleMarker = "_expel_cycle"
7272 // ManagePanesUseCase handles pane tree operations.
7373 type ManagePanesUseCase struct {
7474 idGenerator IDGenerator
75+ normalizer *NavigationURLNormalizer
7576 }
7677
7778 // NewManagePanesUseCase creates a new pane management use case.
78-func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
79+func NewManagePanesUseCase(idGenerator IDGenerator, localPaths port.LocalPathResolver) *ManagePanesUseCase {
7980 return &ManagePanesUseCase{
8081 idGenerator: idGenerator,
82+ normalizer: NewNavigationURLNormalizer(localPaths),
8183 }
8284 }
8385
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+
84104 // SplitPaneInput contains parameters for splitting a pane.
85105 type SplitPaneInput struct {
86106 Workspace *entity.Workspace
@@ -136,12 +156,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
136156 newPane = input.NewPane
137157 } else {
138158 paneID := entity.PaneID(uc.idGenerator())
139- newPane = entity.NewPane(paneID)
140- if input.InitialURL != "" {
141- newPane.URI = domainurl.Normalize(input.InitialURL)
142- } else {
143- newPane.URI = "about:blank"
144- }
159+ newPane = uc.newPane(ctx, paneID, input.InitialURL)
145160 }
146161
147162 // Create new pane node
@@ -982,6 +997,7 @@ func (uc *ManagePanesUseCase) CreateStack(
982997 ctx context.Context,
983998 ws *entity.Workspace,
984999 paneNode *entity.PaneNode,
1000+ initialURL string,
9851001 ) (*CreateStackOutput, error) {
9861002 log := logging.FromContext(ctx)
9871003 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
@@ -1012,7 +1028,7 @@ func (uc *ManagePanesUseCase) CreateStack(
10121028
10131029 // Create new pane
10141030 newPaneID := entity.PaneID(uc.idGenerator())
1015- newPane := entity.NewPane(newPaneID)
1031+ newPane := uc.newPane(ctx, newPaneID, initialURL)
10161032
10171033 // Create child node for the new pane
10181034 newChildNode := &entity.PaneNode{
@@ -1057,6 +1073,7 @@ func (uc *ManagePanesUseCase) AddToStack(
10571073 ws *entity.Workspace,
10581074 stackNode *entity.PaneNode,
10591075 pane *entity.Pane,
1076+ initialURL string,
10601077 ) (*AddToStackOutput, error) {
10611078 log := logging.FromContext(ctx)
10621079
@@ -1073,7 +1090,7 @@ func (uc *ManagePanesUseCase) AddToStack(
10731090 // Create pane if not provided
10741091 if pane == nil {
10751092 paneID := entity.PaneID(uc.idGenerator())
1076- pane = entity.NewPane(paneID)
1093+ pane = uc.newPane(ctx, paneID, initialURL)
10771094 }
10781095
10791096 log.Debug().
M internal/application/usecase/manage_panes_close_stack_test.go +7 -7
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *testing.T) {
11- uc := NewManagePanesUseCase(func() string { return "id" })
11+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
1212 ctx := context.Background()
1313
1414 // Create a stack with 3 panes
@@ -44,7 +44,7 @@ func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *
4444 }
4545
4646 func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T) {
47- uc := NewManagePanesUseCase(func() string { return "id" })
47+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
4848 ctx := context.Background()
4949
5050 // Create a stack with 2 panes
@@ -84,7 +84,7 @@ func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T)
8484 }
8585
8686 func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(t *testing.T) {
87- uc := NewManagePanesUseCase(func() string { return "id" })
87+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
8888 ctx := context.Background()
8989
9090 // Create a stack with 3 panes, middle one is active
@@ -113,7 +113,7 @@ func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(
113113 }
114114
115115 func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonActive(t *testing.T) {
116- uc := NewManagePanesUseCase(func() string { return "id" })
116+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
117117 ctx := context.Background()
118118
119119 // Create a stack with 3 panes, first one is active
@@ -138,7 +138,7 @@ func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonAc
138138 }
139139
140140 func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T) {
141- uc := NewManagePanesUseCase(func() string { return "id" })
141+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
142142 ctx := context.Background()
143143
144144 // Create a split with a stack on the left
@@ -185,7 +185,7 @@ func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T)
185185 }
186186
187187 func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *testing.T) {
188- uc := NewManagePanesUseCase(func() string { return "id" })
188+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
189189 ctx := context.Background()
190190
191191 // Create a stack with 3 panes, first one is active
@@ -218,7 +218,7 @@ func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *tes
218218 }
219219
220220 func TestManagePanesUseCase_CloseStackedPane_ClosingLastPaneUpdatesIndex(t *testing.T) {
221- uc := NewManagePanesUseCase(func() string { return "id" })
221+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
222222 ctx := context.Background()
223223
224224 // 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 (
88 )
99
1010 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11- uc := NewManagePanesUseCase(func() string { return "id" })
11+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
1212 ctx := context.Background()
1313
1414 left := leaf("left")
@@ -61,7 +61,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVertica
6161 }
6262
6363 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64- uc := NewManagePanesUseCase(func() string { return "id" })
64+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
6565 ctx := context.Background()
6666
6767 left := leaf("left")
@@ -114,7 +114,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVertic
114114 }
115115
116116 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117- uc := NewManagePanesUseCase(func() string { return "id" })
117+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
118118 ctx := context.Background()
119119
120120 stackNode := stack(leaf("a"), leaf("b"))
@@ -141,7 +141,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd
141141 }
142142
143143 func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144- uc := NewManagePanesUseCase(func() string { return "id" })
144+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
145145 ctx := context.Background()
146146
147147 top := leaf("top")
@@ -165,7 +165,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStacke
165165 }
166166
167167 func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168- uc := NewManagePanesUseCase(func() string { return "id" })
168+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
169169 ctx := context.Background()
170170
171171 stackNode := stack(leaf("a"), leaf("b"))
@@ -200,7 +200,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin
200200 }
201201
202202 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203- uc := NewManagePanesUseCase(func() string { return "id" })
203+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
204204 ctx := context.Background()
205205
206206 stackNode := stack(leaf("a"), leaf("b"))
@@ -228,7 +228,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalT
228228 }
229229
230230 func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231- uc := NewManagePanesUseCase(func() string { return "id" })
231+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
232232 ctx := context.Background()
233233
234234 stackNode := stack(leaf("a"), leaf("b"))
@@ -259,7 +259,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t
259259 }
260260
261261 func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262- uc := NewManagePanesUseCase(func() string { return "id" })
262+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
263263 ctx := context.Background()
264264
265265 stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
@@ -293,7 +293,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *
293293 }
294294
295295 func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296- uc := NewManagePanesUseCase(func() string { return "id" })
296+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
297297 ctx := context.Background()
298298
299299 left := leaf("left")
@@ -317,7 +317,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *te
317317 }
318318
319319 func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320- uc := NewManagePanesUseCase(func() string { return "id" })
320+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
321321 ctx := context.Background()
322322
323323 a := leaf("a")
@@ -346,7 +346,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSibling
346346 }
347347
348348 func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349- uc := NewManagePanesUseCase(func() string { return "id" })
349+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
350350 ctx := context.Background()
351351
352352 only := leaf("only")
M internal/application/usecase/manage_panes_resize_test.go +5 -5
@@ -9,7 +9,7 @@ import (
99 )
1010
1111 func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
12- uc := NewManagePanesUseCase(func() string { return "id" })
12+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
1313
1414 ctx := context.Background()
1515
@@ -33,7 +33,7 @@ func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
3333 }
3434
3535 func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
36- uc := NewManagePanesUseCase(func() string { return "id" })
36+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
3737 ctx := context.Background()
3838
3939 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
@@ -67,7 +67,7 @@ func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
6767 }
6868
6969 func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {
70- uc := NewManagePanesUseCase(func() string { return "id" })
70+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
7171 ctx := context.Background()
7272
7373 top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
@@ -101,7 +101,7 @@ func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testi
101101 }
102102
103103 func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
104- uc := NewManagePanesUseCase(func() string { return "id" })
104+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
105105 ctx := context.Background()
106106
107107 left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}
@@ -127,7 +127,7 @@ func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
127127 }
128128
129129 func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {
130- uc := NewManagePanesUseCase(func() string { return "id" })
130+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
131131 ctx := context.Background()
132132
133133 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 (
99 )
1010
1111 func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
12- uc := NewManagePanesUseCase(func() string { return "id" })
12+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
1313 ctx := context.Background()
1414
1515 dirs := []SplitDirection{SplitLeft, SplitRight, SplitUp, SplitDown}
@@ -51,7 +51,7 @@ func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
5151 }
5252
5353 func TestManagePanesUseCase_Split_InvalidDirection_ReturnsError(t *testing.T) {
54- uc := NewManagePanesUseCase(func() string { return "id" })
54+ uc := NewManagePanesUseCase(func() string { return "id" }, nil)
5555 ctx := context.Background()
5656
5757 target := leaf("target")
M internal/application/usecase/manage_tabs.go +6 -4
@@ -4,8 +4,8 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/domain/url"
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
@@ -15,12 +15,14 @@ type IDGenerator func() string
1515 // ManageTabsUseCase handles tab lifecycle operations.
1616 type ManageTabsUseCase struct {
1717 idGenerator IDGenerator
18+ normalizer *NavigationURLNormalizer
1819 }
1920
2021 // NewManageTabsUseCase creates a new tab management use case.
21-func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {
22+func NewManageTabsUseCase(idGenerator IDGenerator, localPaths port.LocalPathResolver) *ManageTabsUseCase {
2223 return &ManageTabsUseCase{
2324 idGenerator: idGenerator,
25+ normalizer: NewNavigationURLNormalizer(localPaths),
2426 }
2527 }
2628
@@ -58,7 +60,7 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (
5860 // Create initial pane with normalized URL
5961 pane := entity.NewPane(paneID)
6062 if input.InitialURL != "" {
61- pane.URI = url.Normalize(input.InitialURL)
63+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
6264 }
6365
6466 // Create tab with workspace
@@ -355,7 +357,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab
355357 // Use the provided pane
356358 pane := input.Pane
357359 if input.InitialURL != "" {
358- pane.URI = url.Normalize(input.InitialURL)
360+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
359361 }
360362
361363 // 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 {
227227 }
228228 }
229229
230-// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
230+// CheckRuntimeRequirements verifies GUI runtime dependencies currently covered by runtime checks.
231231 // Returns error if requirements are not met; caller should log details and exit.
232232 // Note: When running in a Flatpak sandbox, runtime checks are skipped because
233233 // the Flatpak runtime provides all required libraries.
M internal/bootstrap/runtime_profile_test.go +10 -1
@@ -1,7 +1,9 @@
11 package bootstrap
22
33 import (
4+ "os"
45 "path/filepath"
6+ "strings"
57 "testing"
68
79 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -52,12 +54,19 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
5254 wd := t.TempDir()
5355 t.Chdir(wd)
5456
57+ runtimeDir, err := os.MkdirTemp("", "dbr-")
58+ require.NoError(t, err)
59+ t.Cleanup(func() {
60+ _ = os.RemoveAll(runtimeDir)
61+ })
5562 t.Setenv("ENV", "dev")
63+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
5664 profile, err := ResolveRuntimeProfile(cfg)
5765 require.NoError(t, err)
5866 require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir)
5967 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"))
6170 }
6271
6372 func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) {
M internal/cli/app.go +21 -15
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/infrastructure/config"
1919 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
2020 "github.com/bnema/dumber/internal/infrastructure/favicon"
21+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
2122 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
2223 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2324 "github.com/bnema/dumber/internal/logging"
@@ -39,8 +40,10 @@ type App struct {
3940 DeleteSessionUC *usecase.DeleteSessionUseCase
4041
4142 // Services
42- FaviconService *favicon.Service
43- SessionSpawner port.SessionSpawner
43+ FaviconService *favicon.Service
44+ SessionSpawner port.SessionSpawner
45+ LocalPaths port.LocalPathResolver
46+ NavigationURLNormalizer *usecase.NavigationURLNormalizer
4447
4548 // Context with logger
4649 ctx context.Context
@@ -128,21 +131,24 @@ func NewApp() (*App, error) {
128131 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
129132 faviconCacheDir, _ := config.GetFaviconCacheDir()
130133 faviconService := favicon.NewService(faviconCacheDir)
134+ localPaths := filesystem.New()
131135
132136 return &App{
133- Config: cfg,
134- Theme: theme,
135- db: db,
136- History: historyRepo,
137- SearchHistoryUC: searchHistoryUC,
138- SessionUC: sessionUC,
139- ListSessionsUC: listSessionsUC,
140- RestoreUC: restoreUC,
141- DeleteSessionUC: deleteSessionUC,
142- FaviconService: faviconService,
143- SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
144- ctx: ctx,
145- logCleanup: logCleanup,
137+ Config: cfg,
138+ Theme: theme,
139+ db: db,
140+ History: historyRepo,
141+ SearchHistoryUC: searchHistoryUC,
142+ SessionUC: sessionUC,
143+ ListSessionsUC: listSessionsUC,
144+ RestoreUC: restoreUC,
145+ DeleteSessionUC: deleteSessionUC,
146+ FaviconService: faviconService,
147+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
148+ LocalPaths: localPaths,
149+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150+ ctx: ctx,
151+ logCleanup: logCleanup,
146152 }, nil
147153 }
148154
M internal/cli/cmd/dmenu.go +13 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/spf13/cobra"
1313
1414 "github.com/bnema/dumber/assets"
15+ "github.com/bnema/dumber/internal/application/usecase"
1516 "github.com/bnema/dumber/internal/cli/model"
1617 "github.com/bnema/dumber/internal/domain/entity"
1718 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -217,10 +218,20 @@ func parseSelection(selection string) string {
217218 return ""
218219 }
219220
220- // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
221+ // Existing local paths are resolved before URL/search fallback so dmenu keeps
222+ // the same file:// behavior as the in-app omnibox.
221223 app := GetApp()
222224 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+ )
224235 }
225236
226237 // 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{
2222 Long: `Doctor checks system prerequisites for running the GUI browser.
2323
2424 By default it runs both:
25-- Runtime checks (GTK4 + WebKitGTK 6.0)
25+- Runtime checks for GTK and WebKit fallback libraries
2626 - Media checks (GStreamer/VA-API)
2727
2828 Use flags to run only one category.
@@ -36,7 +36,7 @@ Examples:
3636
3737 func init() {
3838 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")
4040 doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)")
4141 }
4242 func runDoctor(_ *cobra.Command, _ []string) error {
M internal/cli/cmd/root.go +1 -1
@@ -22,7 +22,7 @@ var (
2222 SilenceUsage: true,
2323 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
2424
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.
2626
2727 Features:
2828 - Wayland native (Sway, Hyprland, River, Niri, etc.)
M internal/cli/cmd/sessions.go +43 -34
@@ -1,6 +1,7 @@
11 package cmd
22
33 import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "os"
@@ -39,29 +40,29 @@ func init() {
3940 }
4041
4142 func runSessions(_ *cobra.Command, _ []string) error {
42- app := GetApp()
43- if app == nil {
43+ cliApp := GetApp()
44+ if cliApp == nil {
4445 return fmt.Errorf("app not initialized")
4546 }
4647
47- if app.ListSessionsUC == nil {
48+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
4849 return fmt.Errorf("session management not available")
4950 }
5051
5152 // Get current session ID (empty if not running as browser)
5253 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 {
5455 currentSessionID = active.ID
5556 }
5657
5758 // Run interactive TUI
58- m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{
59- ListSessionsUC: app.ListSessionsUC,
60- RestoreUC: app.RestoreUC,
61- DeleteSessionUC: app.DeleteSessionUC,
62- SessionSpawner: app.SessionSpawner,
59+ m := model.NewSessionsModel(cliApp.Ctx(), cliApp.Theme, model.SessionsModelConfig{
60+ ListSessionsUC: cliApp.ListSessionsUC,
61+ RestoreUC: cliApp.RestoreUC,
62+ DeleteSessionUC: cliApp.DeleteSessionUC,
63+ SessionSpawner: cliApp.SessionSpawner,
6364 CurrentSession: currentSessionID,
64- MaxExitedSessions: app.Config.Session.MaxExitedSessions,
65+ MaxExitedSessions: cliApp.Config.Session.MaxExitedSessions,
6566 })
6667
6768 p := tea.NewProgram(m, tea.WithAltScreen())
@@ -89,13 +90,13 @@ func init() {
8990 }
9091
9192 func runSessionsList(_ *cobra.Command, _ []string) error {
92- app := GetApp()
93- if app == nil {
93+ cliApp := GetApp()
94+ if cliApp == nil {
9495 return fmt.Errorf("app not initialized")
9596 }
96- renderer := styles.NewSessionsCLIRenderer(app.Theme)
97+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
9798
98- if app.ListSessionsUC == nil {
99+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
99100 err := fmt.Errorf("session management not available")
100101 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101102 return wrapPrintedError(err)
@@ -103,11 +104,11 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
103104
104105 // Get current session ID (empty if not running as browser)
105106 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 {
107108 currentSessionID = active.ID
108109 }
109110
110- output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
111+ output, err := cliApp.ListSessionsUC.Execute(cliApp.Ctx(), currentSessionID, sessionsLimit)
111112 if err != nil {
112113 wrappedErr := fmt.Errorf("list sessions: %w", err)
113114 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
@@ -151,13 +152,13 @@ func init() {
151152 }
152153
153154 func runSessionsRestore(_ *cobra.Command, args []string) error {
154- app := GetApp()
155- if app == nil {
155+ cliApp := GetApp()
156+ if cliApp == nil {
156157 return fmt.Errorf("app not initialized")
157158 }
158- renderer := styles.NewSessionsCLIRenderer(app.Theme)
159+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
159160
160- if app.RestoreUC == nil || app.ListSessionsUC == nil {
161+ if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
161162 err := fmt.Errorf("session restoration not available")
162163 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163164 return wrapPrintedError(err)
@@ -173,7 +174,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
173174 sessionID := sessionInfo.Session.ID
174175
175176 // 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})
177178 if err != nil {
178179 wrappedErr := fmt.Errorf("restore session: %w", err)
179180 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
@@ -181,12 +182,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
181182 }
182183
183184 // Spawn a new dumber instance with the session
184- if app.SessionSpawner == nil {
185+ if cliApp.SessionSpawner == nil {
185186 err := fmt.Errorf("session spawner not available")
186187 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
187188 return wrapPrintedError(err)
188189 }
189- if err := app.SessionSpawner.SpawnWithSession(sessionID); err != nil {
190+ if err := cliApp.SessionSpawner.SpawnWithSession(sessionID); err != nil {
190191 wrappedErr := fmt.Errorf("spawn browser: %w", err)
191192 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
192193 return wrapPrintedError(wrappedErr)
@@ -219,13 +220,13 @@ func init() {
219220 }
220221
221222 func runSessionsDelete(_ *cobra.Command, args []string) error {
222- app := GetApp()
223- if app == nil {
223+ cliApp := GetApp()
224+ if cliApp == nil {
224225 return fmt.Errorf("app not initialized")
225226 }
226- renderer := styles.NewSessionsCLIRenderer(app.Theme)
227+ renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
227228
228- if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
229+ if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
229230 err := fmt.Errorf("session management not available")
230231 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
231232 return wrapPrintedError(err)
@@ -240,12 +241,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
240241
241242 // Get current session ID
242243 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 {
244245 currentSessionID = active.ID
245246 }
246247
247248 // 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{
249250 SessionID: sessionInfo.Session.ID,
250251 CurrentSessionID: currentSessionID,
251252 }); err != nil {
@@ -261,19 +262,27 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
261262 // findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
262263 // Users typically identify sessions by the last few characters (e.g., "dee5").
263264 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
264- app := GetApp()
265- if app == nil || app.ListSessionsUC == nil {
265+ cliApp := GetApp()
266+ if cliApp == nil {
266267 return nil, fmt.Errorf("app not initialized")
267268 }
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+ }
268277
269278 // Get current session ID (empty if not running as browser)
270279 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 {
272281 currentSessionID = active.ID
273282 }
274283
275- // List all sessions
276- output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
284+ // List all sessions so restore/delete suffix lookup can find older sessions.
285+ output, err := cliApp.ListSessionsUC.Execute(ctx, currentSessionID, usecase.ListAllSessionsLimit)
277286 if err != nil {
278287 return nil, fmt.Errorf("list sessions: %w", err)
279288 }
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() {
5757 }
5858
5959 func runSetupInstall(_ *cobra.Command, _ []string) error {
60- app := GetApp()
61- if app == nil {
60+ cliApp := GetApp()
61+ if cliApp == nil {
6262 return fmt.Errorf("app not initialized")
6363 }
6464
65- theme := app.Theme
65+ theme := cliApp.Theme
6666
6767 // Check if installed via package manager
6868 switch {
@@ -87,7 +87,7 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
8787 IconData: assets.LogoSVG,
8888 }
8989
90- result, err := uc.Execute(app.Ctx(), input)
90+ result, err := uc.Execute(cliApp.Ctx(), input)
9191 if err != nil {
9292 fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), err.Error())
9393 return err
@@ -124,16 +124,16 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
124124 }
125125
126126 func runSetupDefault(_ *cobra.Command, _ []string) error {
127- app := GetApp()
128- if app == nil {
127+ cliApp := GetApp()
128+ if cliApp == nil {
129129 return fmt.Errorf("app not initialized")
130130 }
131131
132- theme := app.Theme
132+ theme := cliApp.Theme
133133 adapter := desktop.New()
134134 uc := usecase.NewSetDefaultBrowserUseCase(adapter)
135135
136- result, err := uc.Execute(app.Ctx())
136+ result, err := uc.Execute(cliApp.Ctx())
137137 if err != nil {
138138 errMsg := err.Error()
139139 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 {
203203 return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
204204 }
205205
206- app := GetApp()
207- if app == nil {
206+ cliApp := GetApp()
207+ if cliApp == nil {
208208 return checkResultMsg{err: fmt.Errorf("app not initialized")}
209209 }
210210
211- checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)
211+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, cliApp.BuildInfo)
212212 result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213213
214214 return checkResultMsg{output: result, err: err}
@@ -240,15 +240,15 @@ func (m updateModel) downloadUpdate() tea.Cmd {
240240 }
241241
242242 func runUpdate(_ *cobra.Command, _ []string) error {
243- app := GetApp()
244- if app == nil {
243+ cliApp := GetApp()
244+ if cliApp == nil {
245245 return fmt.Errorf("app not initialized")
246246 }
247247
248- renderer := styles.NewUpdateRenderer(app.Theme)
248+ renderer := styles.NewUpdateRenderer(cliApp.Theme)
249249
250250 // Create and run the bubbletea program.
251- m := newUpdateModel(renderer, app.Theme.Accent, updateForce)
251+ m := newUpdateModel(renderer, cliApp.Theme.Accent, updateForce)
252252 p := tea.NewProgram(m)
253253
254254 finalModel, err := p.Run()
M internal/domain/url/normalize.go +2 -32
@@ -7,21 +7,9 @@ import (
77 "fmt"
88 "net"
99 "net/url"
10- "os"
11- "path/filepath"
1210 "strings"
1311 )
1412
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-
2513 // looksLikeFilePath returns true if the input looks like a filesystem path.
2614 // This includes absolute paths (/path), relative paths (./path, ../path),
2715 // and home-relative paths (~/path).
@@ -41,7 +29,7 @@ func looksLikeFilePath(input string) bool {
4129
4230 // Normalize adds https:// prefix if missing for URL-like inputs.
4331 // 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.
4533 func Normalize(input string) string {
4634 if input == "" {
4735 return ""
@@ -65,29 +53,11 @@ func Normalize(input string) string {
6553 return input
6654 }
6755
68- // Check if input looks like a file path
56+ // Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
6957 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
8058 return input
8159 }
8260
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-
9161 // Looks like a URL (contains . and no spaces)
9262 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
9363 return "http://" + input
M internal/domain/url/normalize_test.go +7 -116
@@ -1,8 +1,6 @@
11 package url
22
33 import (
4- "os"
5- "path/filepath"
64 "strings"
75 "testing"
86 )
@@ -120,128 +118,21 @@ func TestNormalize(t *testing.T) {
120118 }
121119 }
122120
123-func TestNormalize_LocalFiles(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-
121+func TestNormalize_PathLikeInputsArePure(t *testing.T) {
213122 tests := []struct {
214123 name string
215124 input string
216- want string
217125 }{
218- {
219- name: "tilde expansion",
220- input: "~/Documents",
221- want: filepath.Join(home, "Documents"),
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- },
126+ {name: "absolute path", input: "/tmp/example.html"},
127+ {name: "relative path with dot", input: "./example.html"},
128+ {name: "relative parent path", input: "../example.html"},
129+ {name: "home path", input: "~/example.html"},
238130 }
239131
240132 for _, tt := range tests {
241133 t.Run(tt.name, func(t *testing.T) {
242- got := expandHome(tt.input)
243- if got != tt.want {
244- t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
134+ if got := Normalize(tt.input); got != tt.input {
135+ t.Errorf("Normalize(%q) = %q, want unchanged", tt.input, got)
245136 }
246137 })
247138 }
M internal/infrastructure/cef/engine_init_test.go +19 -2
@@ -181,12 +181,18 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
181181 func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
182182 t.Helper()
183183 cwd := t.TempDir()
184+ runtimeDir := shortRuntimeDir(t)
185+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
184186 profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
185187 Env: func(key string) string {
186- if key == "ENV" {
188+ switch key {
189+ case "ENV":
187190 return "dev"
191+ case "XDG_RUNTIME_DIR":
192+ return runtimeDir
193+ default:
194+ return ""
188195 }
189- return ""
190196 },
191197 Engine: "cef",
192198 CWD: func() (string, error) { return cwd, nil },
@@ -195,6 +201,17 @@ func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
195201 return profile
196202 }
197203
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+
198215 func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {
199216 t.Helper()
200217 root := t.TempDir()
M internal/infrastructure/cef/scale_probe.go +2 -2
@@ -41,10 +41,10 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
4141 }
4242 m.UserZoom = normalizeScale(wv.GetZoomLevel())
4343 m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
44- m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale
44+ m.SurfaceScale = normalizeScale(wv.viewBridgeScale())
45+ m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale)
4546 if wv.viewBridge != nil {
4647 m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
47- m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
4848 }
4949 return m
5050 }
M internal/infrastructure/cef/webview.go +37 -23
@@ -214,12 +214,12 @@ type WebView struct {
214214 loadDiagLastLoadStateAt time.Time
215215
216216 // Atomic state.
217- destroyed atomic.Bool
218- fullscreen atomic.Bool
219- generation atomic.Uint64
220- audioPlaying atomic.Bool
221- zoomFactor atomic.Value // float64, initialized to 1.0
222- lastAppliedOSRBackingScaleBits atomic.Uint64
217+ destroyed atomic.Bool
218+ fullscreen atomic.Bool
219+ generation atomic.Uint64
220+ audioPlaying atomic.Bool
221+ zoomFactor atomic.Value // float64, initialized to 1.0
222+ lastAppliedZoomScaleRatioBits atomic.Uint64
223223
224224 // Browser creation defaults copied from the factory so native popup shells
225225 // can apply the same settings in OnBeforePopup.
@@ -491,15 +491,23 @@ func cefZoomFromFactor(factor float64) float64 {
491491 return math.Log(factor) / math.Log(chromiumZoomBase)
492492 }
493493
494-func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {
495- return cefZoomFromFactor(pageZoom * normalizeScale(backingScale))
494+func zoomScaleRatio(surfaceScale, backingScale float64) float64 {
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))
496504 }
497505
498-func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
499- return factorFromCEFZoom(level) / normalizeScale(backingScale)
506+func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 {
507+ return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale)
500508 }
501509
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) {
503511 host.SetZoomLevel(cefLevel)
504512 // Force CEF to produce a new frame at the new zoom level. In OSR mode,
505513 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
@@ -508,7 +516,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel,
508516 // SynchronizeVisualProperties cycle, which makes the renderer produce
509517 // a new compositor frame at the new zoom level.
510518 host.NotifyScreenInfoChanged()
511- wv.recordAppliedOSRBackingScale(backingScale)
519+ wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale)
512520 // Zoom is applied asynchronously in the renderer process. Request a couple
513521 // of follow-up refreshes on the CEF UI thread so OSR captures the updated
514522 // compositor frame after the zoom IPC has been processed.
@@ -541,14 +549,16 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
541549 if host == nil {
542550 return errNoBrowser
543551 }
552+ surfaceScale := wv.viewBridgeScale()
544553 backingScale := wv.osrBackingScaleFactor()
545- cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
554+ cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
546555 logging.FromContext(wv.ctx).Debug().
547556 Float64("factor", factor).
548557 Float64("cef_level", cefLevel).
549558 Float64("osr_backing_scale", backingScale).
559+ Float64("surface_scale", surfaceScale).
550560 Msg("cef: SetZoomLevel")
551- wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
561+ wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
552562 wv.zoomFactor.Store(factor)
553563 return nil
554564 }
@@ -1665,23 +1675,23 @@ func (wv *WebView) osrBackingScaleFactor() float64 {
16651675 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
16661676 }
16671677
1668-func (wv *WebView) recordAppliedOSRBackingScale(scale float64) {
1678+func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) {
16691679 if wv == nil {
16701680 return
16711681 }
1672- wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale)))
1682+ wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale)))
16731683 }
16741684
1675-func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool {
1685+func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool {
16761686 if wv == nil {
16771687 return false
16781688 }
1679- normalized := normalizeScale(scale)
1680- bits := wv.lastAppliedOSRBackingScaleBits.Load()
1689+ ratio := zoomScaleRatio(surfaceScale, backingScale)
1690+ bits := wv.lastAppliedZoomScaleRatioBits.Load()
16811691 if bits == 0 {
16821692 return true
16831693 }
1684- return math.Float64frombits(bits) != normalized
1694+ return math.Float64frombits(bits) != ratio
16851695 }
16861696
16871697 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -1982,16 +1992,18 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
19821992 if host == nil {
19831993 return
19841994 }
1995+ surfaceScale := wv.viewBridgeScale()
19851996 backingScale := wv.osrBackingScaleFactor()
19861997 actualLevel := host.GetZoomLevel()
19871998 logging.FromContext(wv.ctx).Debug().
19881999 Int64("delay_ms", delayMs).
19892000 Float64("expected_factor", expectedFactor).
19902001 Float64("expected_cef_level", expectedLevel).
1991- Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)).
2002+ Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)).
19922003 Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
19932004 Float64("actual_cef_level", actualLevel).
19942005 Float64("osr_backing_scale", backingScale).
2006+ Float64("surface_scale", surfaceScale).
19952007 Msg("cef: zoom level readback")
19962008 }))
19972009 if task == nil {
@@ -2012,14 +2024,16 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
20122024 return
20132025 }
20142026 factor := wv.GetZoomLevel()
2027+ surfaceScale := wv.viewBridgeScale()
20152028 backingScale := wv.osrBackingScaleFactor()
2016- cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
2017- wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
2029+ cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
2030+ wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
20182031 logging.FromContext(wv.ctx).Debug().
20192032 Str("reason", reason).
20202033 Float64("factor", factor).
20212034 Float64("cef_level", cefLevel).
20222035 Float64("osr_backing_scale", backingScale).
2036+ Float64("surface_scale", surfaceScale).
20232037 Msg("cef: reapplied zoom after OSR backing scale change")
20242038 }
20252039
M internal/infrastructure/cef/webview_handlers.go +2 -2
@@ -783,7 +783,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
783783
784784 h.wv.browser = browser
785785 h.wv.host = host
786- h.wv.lastAppliedOSRBackingScaleBits.Store(0)
786+ h.wv.lastAppliedZoomScaleRatioBits.Store(0)
787787 h.wv.pendingCreate = nil
788788 bridge := h.wv.viewBridge
789789 h.wv.inputAttached = bridge == nil
@@ -901,7 +901,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
901901 h.wv.mu.Lock()
902902 h.wv.browser = nil
903903 h.wv.host = nil
904- h.wv.lastAppliedOSRBackingScaleBits.Store(0)
904+ h.wv.lastAppliedZoomScaleRatioBits.Store(0)
905905 h.wv.inputAttached = false
906906 if h.wv.findCtrl != nil {
907907 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
500500 if host == nil || wv == nil {
501501 return
502502 }
503- if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) {
503+ if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) {
504504 return
505505 }
506506 task := cefNewTask(cefTaskFunc(func() {
@@ -513,8 +513,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
513513 if currentHost != host {
514514 return
515515 }
516+ surfaceScale := wv.viewBridgeScale()
516517 backingScale := wv.osrBackingScaleFactor()
517- if !wv.shouldReapplyZoomForBackingScale(backingScale) {
518+ if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) {
518519 return
519520 }
520521 wv.reapplyCurrentZoomForBackingScale(reason)
M internal/infrastructure/cef/webview_zoom_test.go +53 -9
@@ -18,14 +18,58 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1818 }
1919 }
2020
21-func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) {
22- for _, backing := range []float64{1.0, 1.25, 1.44, 2.0} {
23- for _, pageZoom := range []float64{1.0, 1.75} {
24- t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) {
25- level := cefZoomFromPageAndBackingFactor(pageZoom, backing)
26- assert.InDelta(t, pageZoom*backing, factorFromCEFZoom(level), 1e-9)
27- assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9)
28- })
29- }
21+func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ surfaceScale float64
25+ backingScale float64
26+ pageZoom float64
27+ wantInternal float64
28+ }{
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+ },
3057 }
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")
3175 }
M internal/infrastructure/config/loader.go +31 -11
@@ -311,27 +311,47 @@ func bindBrowsingContextEnvAliases(v *viper.Viper) error {
311311
312312 // checkLegacyFormat detects old config format and returns an error directing user to migrate.
313313 func (m *Manager) checkLegacyFormat() error {
314- // Check if old sections exist by looking for known keys.
315314 // IsSet works for legacy keys because they do not have defaults, while
316315 // engine.type has a default and must be checked with InConfig.
317- hasOldSections := m.viper.IsSet("rendering.mode") ||
318- m.viper.IsSet("rendering.disable_dmabuf_renderer") ||
319- m.viper.IsSet("performance.profile") ||
320- m.viper.IsSet("privacy.cookie_policy") ||
321- m.viper.IsSet("runtime.prefix")
316+ hasOldSections := false
317+ for _, alias := range legacyEngineAliases {
318+ if m.viper.IsSet(alias.legacyKey()) {
319+ hasOldSections = true
320+ break
321+ }
322+ }
322323
323- hasEngineSection := m.viper.InConfig("engine.type")
324+ hasEngineSection := m.hasEngineSectionInConfig()
324325
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 {
326334 return fmt.Errorf(
327- "config format outdated: [rendering], [performance], [privacy] sections " +
328- "have moved to [engine]/[engine.webkit] — " +
329- "run \"dumber config migrate\" to update your config file",
335+ "config format outdated: legacy engine keys in %s have moved to "+
336+ "[engine]/[engine.webkit] — run \"dumber config migrate\" "+
337+ "to update your config file",
338+ legacyEngineInputSectionsMessage(),
330339 )
331340 }
332341 return nil
333342 }
334343
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+
335355 func (m *Manager) unmarshalConfig() (*Config, error) {
336356 config := &Config{}
337357 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) {
2929 require.Error(t, err)
3030 require.Contains(t, err.Error(), "dumber config migrate")
3131 }
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+}
3248
3349 func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) {
3450 m := &Manager{viper: viper.New()}
@@ -78,6 +94,50 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) {
7894 require.Contains(t, err.Error(), "dumber config migrate")
7995 }
8096
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+
81141 func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) {
82142 dir := t.TempDir()
83143 configPath := filepath.Join(dir, "config.toml")
M internal/infrastructure/config/migrate_engine.go +169 -89
@@ -2,87 +2,168 @@ package config
22
33 import (
44 "fmt"
5- "github.com/pelletier/go-toml/v2"
65 "io/fs"
76 "os"
7+ "strings"
8+
9+ "github.com/pelletier/go-toml/v2"
810 )
911
1012 // configFilePermissions is the file permission used when writing migrated config files.
1113 const configFilePermissions fs.FileMode = 0o644
1214
13-// engineMigrationMapping defines old key -> new key mappings for the engine migration.
14-// Keys with empty new key ("") are dropped (not migrated).
15-// The source section is the first part of the old key.
16-type engineFieldMapping struct {
17- oldSection string
18- oldField string
19- newSection string // "engine" or "engine.webkit"
20- newField string // empty means drop
15+// legacyEngineAlias defines the single metadata source for old engine config keys.
16+// Dropped aliases are detected as legacy input but are not written to a new target.
17+type legacyEngineAlias struct {
18+ legacySection string
19+ legacyField string
20+ targetSection string // "engine" or "engine.webkit"; empty when dropped
21+ targetField string // empty when dropped
22+ dropped bool
2123 }
2224
23-// engineUniversalMappings are fields that go into [engine] (not [engine.webkit]).
24-var engineUniversalMappings = []engineFieldMapping{
25+func (a legacyEngineAlias) legacyKey() string {
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{
2553 // [performance] -> [engine]
26- {oldSection: "performance", oldField: "profile", newSection: "engine", newField: "profile"},
27- {oldSection: "performance", oldField: "zoom_cache_size", newSection: "engine", newField: "zoom_cache_size"},
28- {oldSection: "performance", oldField: "webview_pool_prewarm_count", newSection: "engine", newField: "pool_prewarm_count"},
54+ legacyEngineSameTarget("performance", "profile", engineTargetSection),
55+ legacyEngineSameTarget("performance", "zoom_cache_size", engineTargetSection),
56+ legacyEngineTarget("performance", "webview_pool_prewarm_count", engineTargetSection, "pool_prewarm_count"),
2957 // [privacy] -> [engine]
30- {oldSection: "privacy", oldField: "cookie_policy", newSection: "engine", newField: "cookie_policy"},
31-}
58+ legacyEngineSameTarget("privacy", "cookie_policy", engineTargetSection),
3259
33-// engineWebkitMappings are fields that go into [engine.webkit].
34-var engineWebkitMappings = []engineFieldMapping{
3560 // [privacy] -> [engine.webkit]
36- {oldSection: "privacy", oldField: "itp_enabled", newSection: "engine.webkit", newField: "itp_enabled"},
61+ legacyEngineSameTarget("privacy", "itp_enabled", engineWebKitTargetSection),
3762
3863 // [rendering] -> [engine.webkit] (rendering.mode is dropped)
39- {oldSection: "rendering", oldField: "disable_dmabuf_renderer", newSection: "engine.webkit", newField: "disable_dmabuf_renderer"},
40- {oldSection: "rendering", oldField: "force_compositing_mode", newSection: "engine.webkit", newField: "force_compositing_mode"},
41- {oldSection: "rendering", oldField: "disable_compositing_mode", newSection: "engine.webkit", newField: "disable_compositing_mode"},
42- {oldSection: "rendering", oldField: "gsk_renderer", newSection: "engine.webkit", newField: "gsk_renderer"},
43- {oldSection: "rendering", oldField: "disable_mipmaps", newSection: "engine.webkit", newField: "disable_mipmaps"},
44- {oldSection: "rendering", oldField: "prefer_gl", newSection: "engine.webkit", newField: "prefer_gl"},
45- {oldSection: "rendering", oldField: "draw_compositing_indicators", newSection: "engine.webkit", newField: "draw_compositing_indicators"},
46- {oldSection: "rendering", oldField: "show_fps", newSection: "engine.webkit", newField: "show_fps"},
47- {oldSection: "rendering", oldField: "sample_memory", newSection: "engine.webkit", newField: "sample_memory"},
48- {oldSection: "rendering", oldField: "debug_frames", newSection: "engine.webkit", newField: "debug_frames"},
64+ droppedLegacyEngineAlias("rendering", "mode"),
65+ legacyEngineSameTarget("rendering", "disable_dmabuf_renderer", engineWebKitTargetSection),
66+ legacyEngineSameTarget("rendering", "force_compositing_mode", engineWebKitTargetSection),
67+ legacyEngineSameTarget("rendering", "disable_compositing_mode", engineWebKitTargetSection),
68+ legacyEngineSameTarget("rendering", "gsk_renderer", engineWebKitTargetSection),
69+ legacyEngineSameTarget("rendering", "disable_mipmaps", engineWebKitTargetSection),
70+ legacyEngineSameTarget("rendering", "prefer_gl", engineWebKitTargetSection),
71+ legacyEngineSameTarget("rendering", "draw_compositing_indicators", engineWebKitTargetSection),
72+ legacyEngineSameTarget("rendering", "show_fps", engineWebKitTargetSection),
73+ legacyEngineSameTarget("rendering", "sample_memory", engineWebKitTargetSection),
74+ legacyEngineSameTarget("rendering", "debug_frames", engineWebKitTargetSection),
4975
5076 // [performance] -> [engine.webkit] (webkit-specific) — field names are identical
51- {oldSection: "performance", oldField: "skia_cpu_painting_threads",
52- newSection: "engine.webkit", newField: "skia_cpu_painting_threads"},
53- {oldSection: "performance", oldField: "skia_gpu_painting_threads",
54- newSection: "engine.webkit", newField: "skia_gpu_painting_threads"},
55- {oldSection: "performance", oldField: "skia_enable_cpu_rendering",
56- newSection: "engine.webkit", newField: "skia_enable_cpu_rendering"},
57- {oldSection: "performance", oldField: "web_process_memory_limit_mb",
58- newSection: "engine.webkit", newField: "web_process_memory_limit_mb"},
59- {oldSection: "performance", oldField: "web_process_memory_poll_interval_sec",
60- newSection: "engine.webkit", newField: "web_process_memory_poll_interval_sec"},
61- {oldSection: "performance", oldField: "web_process_memory_conservative_threshold",
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"},
77+ legacyEngineSameTarget("performance", "skia_cpu_painting_threads", engineWebKitTargetSection),
78+ legacyEngineSameTarget("performance", "skia_gpu_painting_threads", engineWebKitTargetSection),
79+ legacyEngineSameTarget("performance", "skia_enable_cpu_rendering", engineWebKitTargetSection),
80+ legacyEngineSameTarget("performance", "web_process_memory_limit_mb", engineWebKitTargetSection),
81+ legacyEngineSameTarget("performance", "web_process_memory_poll_interval_sec", engineWebKitTargetSection),
82+ legacyEngineSameTarget("performance", "web_process_memory_conservative_threshold", engineWebKitTargetSection),
83+ legacyEngineSameTarget("performance", "web_process_memory_strict_threshold", engineWebKitTargetSection),
84+ legacyEngineSameTarget("performance", "network_process_memory_limit_mb", engineWebKitTargetSection),
85+ legacyEngineSameTarget("performance", "network_process_memory_poll_interval_sec", engineWebKitTargetSection),
86+ legacyEngineSameTarget("performance", "network_process_memory_conservative_threshold", engineWebKitTargetSection),
87+ legacyEngineSameTarget("performance", "network_process_memory_strict_threshold", engineWebKitTargetSection),
7388
7489 // [media] GStreamer fields -> [engine.webkit]
75- {oldSection: "media", oldField: "force_vsync", newSection: "engine.webkit", newField: "force_vsync"},
76- {oldSection: "media", oldField: "gl_rendering_mode", newSection: "engine.webkit", newField: "gl_rendering_mode"},
77- {oldSection: "media", oldField: "gstreamer_debug_level", newSection: "engine.webkit", newField: "gstreamer_debug_level"},
90+ legacyEngineSameTarget("media", "force_vsync", engineWebKitTargetSection),
91+ legacyEngineSameTarget("media", "gl_rendering_mode", engineWebKitTargetSection),
92+ legacyEngineSameTarget("media", "gstreamer_debug_level", engineWebKitTargetSection),
7893
7994 // [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
81106 }
82107
83-// oldSectionsToRemove are the sections that are fully removed after migration
84-// (media is not here because it has fields that stay).
85-var oldSectionsToRemove = []string{"rendering", "performance", "privacy", "runtime"}
108+func legacyEngineSectionsToRemove() []string {
109+ sections := make([]string, 0)
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+}
86167
87168 // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].
88169 // 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) {
93174 }
94175
95176 if _, exists := raw["engine"]; exists {
96- for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
177+ for _, section := range legacyEngineSectionsToRemove() {
97178 if _, hasLegacy := raw[section]; hasLegacy {
98179 return false, fmt.Errorf(
99180 "mixed old/new config: [engine] coexists with legacy [%s]; "+
@@ -102,16 +183,13 @@ func MigrateToEngineConfig(configFile string) (bool, error) {
102183 )
103184 }
104185 }
105- // Check for deprecated media keys that moved to [engine.webkit].
106- if mediaRaw, ok := raw["media"].(map[string]any); ok {
107- for _, key := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
108- if _, has := mediaRaw[key]; has {
109- return false, fmt.Errorf(
110- "mixed old/new config: [engine] coexists with deprecated "+
111- "media.%s; remove it from [media] (now in [engine.webkit])",
112- key,
113- )
114- }
186+ for _, alias := range legacyEngineAliasesForSection("media") {
187+ if hasLegacyEngineAlias(raw, alias) {
188+ return false, fmt.Errorf(
189+ "mixed old/new config: [engine] coexists with deprecated "+
190+ "media.%s; remove it from [media] (now in [engine.webkit])",
191+ alias.legacyField,
192+ )
115193 }
116194 }
117195 return false, nil // already migrated
@@ -150,18 +228,14 @@ func readRawTOML(path string) (map[string]any, error) {
150228
151229 // hasOldEngineSections checks if the raw config has old sections that need migration.
152230 func hasOldEngineSections(raw map[string]any) bool {
153- for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
231+ for _, section := range legacyEngineSectionsToRemove() {
154232 if _, exists := raw[section]; exists {
155233 return true
156234 }
157235 }
158- if mediaAny, exists := raw["media"]; exists {
159- if media, ok := mediaAny.(map[string]any); ok {
160- for _, f := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
161- if _, has := media[f]; has {
162- return true
163- }
164- }
236+ for _, alias := range legacyEngineAliasesForSection("media") {
237+ if hasLegacyEngineAlias(raw, alias) {
238+ return true
165239 }
166240 }
167241 return false
@@ -172,14 +246,20 @@ func applyEngineMappings(raw map[string]any) {
172246 engineMap := make(map[string]any)
173247 webkitMap := make(map[string]any)
174248
175- for _, m := range engineUniversalMappings {
176- if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
177- engineMap[m.newField] = val
249+ for _, alias := range legacyEngineAliases {
250+ if alias.dropped {
251+ continue
178252 }
179- }
180- for _, m := range engineWebkitMappings {
181- if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
182- webkitMap[m.newField] = val
253+ val := getSectionField(raw, alias.legacySection, alias.legacyField)
254+ if val == nil {
255+ continue
256+ }
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
183263 }
184264 }
185265
@@ -189,14 +269,14 @@ func applyEngineMappings(raw map[string]any) {
189269 }
190270 raw["engine"] = engineMap
191271
192- for _, section := range oldSectionsToRemove {
272+ for _, section := range legacyEngineSectionsToRemove() {
193273 delete(raw, section)
194274 }
195275
196276 stripMigratedMediaFields(raw)
197277 }
198278
199-// stripMigratedMediaFields removes GStreamer fields from [media] while keeping the rest.
279+// stripMigratedMediaFields removes migrated fields from [media] while keeping the rest.
200280 func stripMigratedMediaFields(raw map[string]any) {
201281 mediaAny, exists := raw["media"]
202282 if !exists {
@@ -206,9 +286,9 @@ func stripMigratedMediaFields(raw map[string]any) {
206286 if !ok {
207287 return
208288 }
209- delete(media, "force_vsync")
210- delete(media, "gl_rendering_mode")
211- delete(media, "gstreamer_debug_level")
289+ for _, alias := range legacyEngineAliasesForSection("media") {
290+ delete(media, alias.legacyField)
291+ }
212292 if len(media) == 0 {
213293 delete(raw, "media")
214294 }
M internal/infrastructure/config/migrate_engine_test.go +73 -2
@@ -89,8 +89,8 @@ func TestMigrateToEngineConfig_OldFormat(t *testing.T) {
8989 assert.Equal(t, int64(50), engine["zoom_cache_size"])
9090 assert.Equal(t, int64(2), engine["pool_prewarm_count"])
9191
92- // [privacy] -> [engine] universal field
93- assert.Equal(t, "accept_all", engine["cookie_policy"])
92+ // [privacy] -> [engine] universal field, translated to canonical schema value
93+ assert.Equal(t, "always", engine["cookie_policy"])
9494
9595 // [engine.webkit] section
9696 webkitAny, ok := engine["webkit"]
@@ -253,6 +253,77 @@ disable_dmabuf_renderer = false
253253 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
254254 }
255255
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+
256327 func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {
257328 tmpDir := t.TempDir()
258329 configFile := filepath.Join(tmpDir, "config.toml")
M internal/infrastructure/config/schema_provider.go +62 -0
@@ -18,6 +18,7 @@ const (
1818 SectionContentFiltering = "Content Filtering"
1919 SectionRendering = "Rendering"
2020 SectionMedia = "Media"
21+ SectionPrivacy = "Privacy"
2122 SectionUpdate = "Update"
2223 SectionDmenu = "Dmenu"
2324 SectionDebug = "Debug"
@@ -78,6 +79,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
7879 // Media section
7980 keys = append(keys, p.getMediaKeys(defaults)...)
8081
82+ // Privacy section
83+ keys = append(keys, p.getPrivacyKeys(defaults)...)
84+
8185 // Update section
8286 keys = append(keys, p.getUpdateKeys(defaults)...)
8387
@@ -271,6 +275,13 @@ func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo {
271275 Description: "Capture browser console messages to log",
272276 Section: SectionLogging,
273277 },
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+ },
274285 }
275286 }
276287
@@ -776,6 +787,14 @@ func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo
776787
777788 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
778789 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+ },
779798 {
780799 Key: "engine.webkit.gsk_renderer",
781800 Type: "string",
@@ -874,6 +893,49 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo {
874893 Description: "Show media capability warnings at startup",
875894 Section: SectionMedia,
876895 },
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+ },
877939 }
878940 }
879941
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 (
44 "fmt"
55 "math"
66 "net/url"
7+ "os"
8+ "path/filepath"
79 "sort"
810 "strings"
911
@@ -30,6 +32,7 @@ func validateConfig(config *Config) error {
3032 validationErrors = append(validationErrors, validateLogging(config)...)
3133 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
3234 validationErrors = append(validationErrors, validateOmnibox(config)...)
35+ validationErrors = append(validationErrors, validateEngine(config)...)
3336 validationErrors = append(validationErrors, validateRendering(config)...)
3437 validationErrors = append(validationErrors, validatePrivacy(config)...)
3538 validationErrors = append(validationErrors, validateColorScheme(config)...)
@@ -443,6 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
443446
444447 func validateWorkspaceURLValue(fieldPath, value string) []string {
445448 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+ }
446462 candidate := trimmed
447463 parsed, err := url.Parse(candidate)
448464 if err != nil || parsed.Scheme == "" {
@@ -465,6 +481,62 @@ func validateWorkspaceURLValue(fieldPath, value string) []string {
465481 }
466482 }
467483
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+
468540 func validateOmnibox(config *Config) []string {
469541 var validationErrors []string
470542 if config.Omnibox.MostVisitedDays < 0 {
@@ -484,6 +556,18 @@ func validateOmnibox(config *Config) []string {
484556 return validationErrors
485557 }
486558
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+
487571 func validateRendering(config *Config) []string {
488572 var validationErrors []string
489573
@@ -499,6 +583,25 @@ func validateRendering(config *Config) []string {
499583 )
500584 }
501585
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+
502605 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
503606 validationErrors = append(validationErrors,
504607 "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
22
33 import (
44 "math"
5+ "os"
6+ "path/filepath"
57 "testing"
68
79 "github.com/stretchr/testify/assert"
810 "github.com/stretchr/testify/require"
911 )
1012
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+
1166 func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
1267 tests := []struct {
1368 name string
@@ -36,6 +91,76 @@ func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
3691 }
3792 }
3893
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+
39164 func TestValidateConfig_CEFConfig(t *testing.T) {
40165 tests := []struct {
41166 name string
@@ -169,6 +294,97 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
169294 }
170295 }
171296
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+
172388 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
173389 cfg := DefaultConfig()
174390 cfg.Engine.Profile = ProfileDefault
M internal/infrastructure/config/xdg_test.go +7 -0
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "os"
45 "path/filepath"
56 "testing"
67
@@ -29,6 +30,12 @@ func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) {
2930 t.Chdir(wd)
3031
3132 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)
3239 dirs, err := GetXDGDirs()
3340 require.NoError(t, err)
3441 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 {
186186 return conn.SetDeadline(deadline)
187187 }
188188
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+
189216 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
190217 socketPath, err := r.socketPath()
191218 if err != nil {
@@ -195,6 +222,9 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
195222 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
196223 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
197224 }
225+ if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
226+ return nil, ownerErr
227+ }
198228 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
199229 if err != nil {
200230 if !errors.Is(err, syscall.EADDRINUSE) {
@@ -209,6 +239,10 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
209239 return nil, errors.New("browser launch relay already running")
210240 }
211241
242+ if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
243+ return nil, ownerErr
244+ }
245+
212246 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
213247 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
214248 }
M internal/infrastructure/desktop/browser_launch_relay_test.go +38 -0
@@ -51,6 +51,44 @@ func shortTempDir(t *testing.T) string {
5151 return dir
5252 }
5353
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+
5492 func testIPC(root string) runtimeprofile.IPCPaths {
5593 runtimeDir := filepath.Join(root, "runtime")
5694 return runtimeprofile.IPCPaths{
M internal/infrastructure/filesystem/adapter.go +28 -0
@@ -67,4 +67,32 @@ func (a *Adapter) RemoveAll(_ context.Context, path string) error {
6767 return os.RemoveAll(path)
6868 }
6969
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+
7097 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 @@
11 package gtkutil
22
3-import (
4- "math"
5-
6- "github.com/bnema/puregotk/v4/gtk"
7-)
3+import "math"
84
95 // NormalizeScale clamps invalid GTK/GDK scale values to 1.
106 func NormalizeScale(scale float64) float64 {
@@ -14,14 +10,6 @@ func NormalizeScale(scale float64) float64 {
1410 return scale
1511 }
1612
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-
2513 // DeviceToLogical converts a device-pixel coordinate into a logical coordinate.
2614 func DeviceToLogical(value int32, scale float64) int32 {
2715 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) {
6969 }
7070
7171 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
72- if limit <= 0 {
72+ if limit == 0 {
7373 limit = 20
7474 }
75+ if limit < 0 {
76+ limit = -1
77+ }
7578 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
7679 if err != nil {
7780 return nil, err
M internal/infrastructure/runtimeprofile/profile.go +39 -1
@@ -1,6 +1,8 @@
11 package runtimeprofile
22
33 import (
4+ "crypto/sha256"
5+ "encoding/hex"
46 "fmt"
57 "path/filepath"
68 "strings"
@@ -8,6 +10,7 @@ import (
810
911 const (
1012 browserLaunchSocketName = "browser-launch.sock"
13+ devIPCSocketPathLimit = 104
1114 engineWebKit = "webkit"
1215 engineCEF = "cef"
1316 )
@@ -82,7 +85,10 @@ func Resolve(input ResolveInput) (Profile, error) {
8285 }
8386 root := filepath.Join(cwd, ".dev", "dumber")
8487 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+ }
8692 return Profile{
8793 Mode: ModeDev,
8894 Engine: engine,
@@ -133,6 +139,38 @@ func Resolve(input ResolveInput) (Profile, error) {
133139 }, nil
134140 }
135141
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+
136174 // normalizeEngine trims and lowercases the runtime engine name.
137175 // Supported engines are "webkit" and "cef"; any other value falls back to
138176 // the default "webkit" runtime namespace.
M internal/infrastructure/runtimeprofile/profile_test.go +70 -0
@@ -2,6 +2,7 @@ package runtimeprofile
22
33 import (
44 "errors"
5+ "strings"
56 "testing"
67
78 "github.com/stretchr/testify/require"
@@ -50,6 +51,75 @@ func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) {
5051 require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
5152 }
5253
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+
53123 func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) {
54124 devEnv := func(key string) string {
55125 if key == "ENV" {
M internal/infrastructure/updater/github.go +2 -8
@@ -275,14 +275,8 @@ func isRetryableRequestError(err error) bool {
275275 }
276276
277277 var netErr net.Error
278- if errors.As(err, &netErr) {
279- if netErr.Timeout() {
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- }
278+ if errors.As(err, &netErr) && netErr.Timeout() {
279+ return true
286280 }
287281
288282 var opErr *net.OpError
M internal/infrastructure/updater/github_test.go +94 -0
@@ -6,11 +6,13 @@ import (
66 "encoding/hex"
77 "errors"
88 "io"
9+ "net"
910 "net/http"
1011 "os"
1112 "path/filepath"
1213 "strings"
1314 "sync/atomic"
15+ "syscall"
1416 "testing"
1517 "time"
1618
@@ -393,6 +395,31 @@ func TestRetryDelayForAttemptAddsJitter(t *testing.T) {
393395 }
394396 }
395397
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+
396423 func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
397424 var calls int32
398425 tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
@@ -427,6 +454,73 @@ func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
427454 }
428455 }
429456
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+
430524 func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) {
431525 var calls int32
432526 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) {
343343 a.setupPoolBackgroundColor(ctx)
344344 a.initLayoutInfrastructure()
345345
346- if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
346+ if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {
347347 log.Error().Err(err).Msg("failed to create main window")
348348 return
349349 }
@@ -446,78 +446,6 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
446446 pool.PrewarmAsync(ctx, 0)
447447 }
448448
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-
521449 func (a *App) initBrowserWindowOverlays(mainWindow *window.MainWindow, browserWindow *browserWindow, runtimeCfg entity.RuntimeUIConfig) {
522450 // Create permission popup and dialog presenter
523451 if a.deps != nil && a.deps.PermissionUC != nil {
@@ -830,192 +758,6 @@ func (a *App) closeBrowserLaunchRelayListener() {
830758 a.browserLaunchRelayCloser = nil
831759 }
832760
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-
1019761 func (a *App) initialWindowURL() string {
1020762 if a.deps != nil {
1021763 return urlutil.ResolveBrowserStartupURL(a.deps.InitialURL)
@@ -1437,12 +1179,13 @@ func (a *App) Cancel(ctx context.Context) {
14371179 }
14381180
14391181 type omniboxCallbacks struct {
1440- OnNavigate func(ctx context.Context, url string) error
1441- OnToast func(ctx context.Context, message string, level component.ToastLevel)
1442- OnFocusIn func(entry *gtk.SearchEntry)
1443- OnFocusOut func()
1444- OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1445- OnAccentKeyRelease func(keyval uint)
1182+ OnNavigate func(ctx context.Context, url string) error
1183+ NormalizeNavigationURL func(ctx context.Context, input string) string
1184+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
1185+ OnFocusIn func(entry *gtk.SearchEntry)
1186+ OnFocusOut func()
1187+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1188+ OnAccentKeyRelease func(keyval uint)
14461189 }
14471190
14481191 func buildOmniboxConfig(
@@ -1464,22 +1207,23 @@ func buildOmniboxConfig(
14641207 }
14651208
14661209 return component.OmniboxConfig{
1467- HistoryUC: deps.HistoryUC,
1468- FavoritesUC: deps.FavoritesUC,
1469- FaviconAdapter: faviconAdapter,
1470- CopyURLUC: deps.CopyURLUC,
1471- ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1472- DefaultSearch: runtimeCfg.DefaultSearchEngine,
1473- InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1474- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1475- SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1476- UIScale: runtimeCfg.DefaultUIScale,
1477- OnNavigate: callbacks.OnNavigate,
1478- OnToast: callbacks.OnToast,
1479- OnFocusIn: callbacks.OnFocusIn,
1480- OnFocusOut: callbacks.OnFocusOut,
1481- OnAccentKeyPress: callbacks.OnAccentKeyPress,
1482- OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
1210+ HistoryUC: deps.HistoryUC,
1211+ FavoritesUC: deps.FavoritesUC,
1212+ FaviconAdapter: faviconAdapter,
1213+ CopyURLUC: deps.CopyURLUC,
1214+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1215+ DefaultSearch: runtimeCfg.DefaultSearchEngine,
1216+ NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1217+ InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1218+ MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1219+ SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1220+ UIScale: runtimeCfg.DefaultUIScale,
1221+ OnNavigate: callbacks.OnNavigate,
1222+ OnToast: callbacks.OnToast,
1223+ OnFocusIn: callbacks.OnFocusIn,
1224+ OnFocusOut: callbacks.OnFocusOut,
1225+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
1226+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
14831227 }
14841228 }
14851229
@@ -1506,10 +1250,19 @@ func NewStandaloneOmniboxRuntime(
15061250 if deps != nil && deps.RuntimeConfig != nil {
15071251 runtimeCfg = deps.RuntimeConfig.Current().UI
15081252 }
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+ }
15091261 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{
15101262 OnNavigate: func(navCtx context.Context, url string) error {
15111263 return handleStandaloneOmniboxNavigation(deps, navCtx, url)
15121264 },
1265+ NormalizeNavigationURL: normalizeNavigationURL,
15131266 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
15141267 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
15151268 },
@@ -1548,10 +1301,17 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
15481301 return
15491302 }
15501303
1304+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil)
15511305 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{
15521306 OnNavigate: func(navCtx context.Context, url string) error {
15531307 return a.navigateFromOmnibox(navCtx, url)
15541308 },
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+ },
15551315 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
15561316 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
15571317 },
@@ -2599,7 +2359,7 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
25992359 }
26002360 a.activateBrowserWindow(bw)
26012361 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2602- _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(newPaneURL))
2362+ _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL)
26032363 return err
26042364 }
26052365
@@ -2695,8 +2455,10 @@ func (a *App) createInitialTab(ctx context.Context) {
26952455 }
26962456 }
26972457
2458+ focusedWindow := a.lastFocusedBrowserWindow()
2459+
26982460 // Create an initial tab using coordinator.
2699- target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())
2461+ target := a.ensureTabTargetForBrowserWindow(focusedWindow)
27002462 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
27012463 log.Error().Err(err).Msg("failed to create initial tab")
27022464 }
@@ -3507,7 +3269,7 @@ func (a *App) keyboardActions() dispatcher.KeyboardActions {
35073269 return fmt.Errorf("newPaneURL is not configured")
35083270 }
35093271 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)
35113273 return err
35123274 })
35133275 },
M internal/ui/app_browser_launch_test.go +12 -12
@@ -1104,7 +1104,7 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
11041104 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
11051105 app := &App{
11061106 tabs: entity.NewTabList(),
1107- tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
1107+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
11081108 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
11091109 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
11101110 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
@@ -1167,7 +1167,7 @@ func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
11671167 ctx := context.Background()
11681168 sessionID := entity.SessionID("session-empty-windows-fallback")
11691169 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)
11711171 windowTabs := entity.NewTabList()
11721172 bw := &browserWindow{id: "window-1", tabs: windowTabs}
11731173 app := &App{
@@ -1361,7 +1361,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
13611361
13621362 app := &App{
13631363 tabs: entity.NewTabList(),
1364- tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
1364+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
13651365 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
13661366 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
13671367 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
@@ -1403,7 +1403,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
14031403 setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
14041404 setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
14051405 tabs := entity.NewTabList()
1406- tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
1406+ tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) }, nil)
14071407
14081408 existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
14091409 existingTabs := entity.NewTabList()
@@ -2280,7 +2280,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
22802280 deps: &Dependencies{},
22812281 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
22822282 tabs: globalTabs,
2283- tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),
2283+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }, nil),
22842284 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
22852285 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
22862286 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
@@ -2341,7 +2341,7 @@ func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T
23412341 deps: &Dependencies{},
23422342 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
23432343 tabs: entity.NewTabList(),
2344- tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }),
2344+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }, nil),
23452345 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
23462346 lastFocusedWindowID: first.id,
23472347 mainWindow: first.mainWindow,
@@ -2973,7 +2973,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testin
29732973 app := &App{
29742974 deps: &Dependencies{},
29752975 runtimeConfig: runtimeConfigStateForTest(cfg),
2976- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2976+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
29772977 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
29782978 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
29792979 lastFocusedWindowID: first.id, // stale — should NOT be used
@@ -3016,7 +3016,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t
30163016 app := &App{
30173017 deps: &Dependencies{},
30183018 runtimeConfig: runtimeConfigStateForTest(cfg),
3019- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
3019+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
30203020 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
30213021 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
30223022 lastFocusedWindowID: first.id, // stale — should NOT be used
@@ -3062,7 +3062,7 @@ func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
30623062 app := &App{
30633063 deps: &Dependencies{},
30643064 runtimeConfig: runtimeConfigStateForTest(cfg),
3065- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
3065+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
30663066 browserWindows: map[string]*browserWindow{second.id: second},
30673067 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
30683068 }
@@ -3089,7 +3089,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindo
30893089 app := &App{
30903090 deps: &Dependencies{},
30913091 runtimeConfig: runtimeConfigStateForTest(cfg),
3092- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
3092+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
30933093 browserWindows: map[string]*browserWindow{second.id: second},
30943094 lastFocusedWindowID: "window-1",
30953095 }
@@ -3119,7 +3119,7 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab
31193119 app := &App{
31203120 deps: &Dependencies{},
31213121 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3122- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
3122+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
31233123 browserWindows: map[string]*browserWindow{second.id: second},
31243124 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
31253125 }
@@ -3145,7 +3145,7 @@ func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *te
31453145 app := &App{
31463146 deps: &Dependencies{},
31473147 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3148- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
3148+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
31493149 browserWindows: map[string]*browserWindow{second.id: second},
31503150 lastFocusedWindowID: "window-1",
31513151 }
M internal/ui/app_omnibox_config_test.go +12 -1
@@ -1,6 +1,7 @@
11 package ui
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/bnema/dumber/internal/domain/entity"
@@ -23,7 +24,11 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
2324 },
2425 }
2526
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+ })
2732 if got.MostVisitedDays != 7 {
2833 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)
2934 }
@@ -39,4 +44,10 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
3944 if got.ShortcutsUC == nil {
4045 t.Fatal("ShortcutsUC is nil")
4146 }
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+ }
4253 }
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) {
709709
710710 bw := &browserWindow{id: "window-1", tabs: bwTabs}
711711
712- panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" })
712+ panesUC := usecase.NewManagePanesUseCase(func() string { return "pane-2" }, nil)
713713 wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
714714 PanesUC: panesUC,
715715 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) {
139139 app := &App{
140140 deps: &Dependencies{},
141141 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
142- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
142+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
143143 contentCoord: contentCoord,
144144 browserWindows: map[string]*browserWindow{bw.id: bw},
145145 tabs: entity.NewTabList(),
@@ -183,7 +183,7 @@ func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
183183 app := &App{
184184 deps: &Dependencies{},
185185 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
186- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
186+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
187187 contentCoord: contentCoord,
188188 browserWindows: map[string]*browserWindow{bw.id: bw},
189189 tabs: entity.NewTabList(),
@@ -218,7 +218,7 @@ func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *test
218218 app := &App{
219219 deps: &Dependencies{},
220220 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
221- tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
221+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
222222 contentCoord: contentCoord,
223223 browserWindows: map[string]*browserWindow{bw.id: bw},
224224 tabs: entity.NewTabList(),
@@ -459,6 +459,74 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
459459 }
460460 }
461461
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+
462530 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
463531 app := &App{browserWindows: make(map[string]*browserWindow)}
464532 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
M internal/ui/component/omnibox.go +132 -51
@@ -39,6 +39,19 @@ type favoriteRowIndicatorUpdate struct {
3939 ShowStarSlot bool
4040 }
4141
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+
4255 // ViewMode distinguishes history search from favorites display.
4356 type ViewMode string
4457
@@ -108,16 +121,17 @@ type Omnibox struct {
108121 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
109122
110123 // Dependencies
111- historyUC *usecase.SearchHistoryUseCase
112- favoritesUC *usecase.ManageFavoritesUseCase
113- faviconAdapter *adapter.FaviconAdapter
114- copyURLUC *usecase.CopyURLUseCase
115- shortcutsUC *usecase.SearchShortcutsUseCase
116- defaultSearch string
117- initialBehavior entity.OmniboxInitialBehavior
118- mostVisitedDays int
119- saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
120- ctx context.Context
124+ historyUC *usecase.SearchHistoryUseCase
125+ favoritesUC *usecase.ManageFavoritesUseCase
126+ faviconAdapter *adapter.FaviconAdapter
127+ copyURLUC *usecase.CopyURLUseCase
128+ shortcutsUC *usecase.SearchShortcutsUseCase
129+ defaultSearch string
130+ normalizeNavigationURL func(ctx context.Context, input string) string
131+ initialBehavior entity.OmniboxInitialBehavior
132+ mostVisitedDays int
133+ saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
134+ ctx context.Context
121135
122136 // Callbacks
123137 onNavigate func(ctx context.Context, url string) error
@@ -155,16 +169,19 @@ type Omnibox struct {
155169
156170 // OmniboxConfig holds configuration for creating an Omnibox.
157171 type OmniboxConfig struct {
158- HistoryUC *usecase.SearchHistoryUseCase
159- FavoritesUC *usecase.ManageFavoritesUseCase
160- FaviconAdapter *adapter.FaviconAdapter
161- CopyURLUC *usecase.CopyURLUseCase
162- ShortcutsUC *usecase.SearchShortcutsUseCase
163- DefaultSearch string
164- InitialBehavior entity.OmniboxInitialBehavior
165- MostVisitedDays int
166- SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
167- UIScale float64 // UI scale for favicon sizing
172+ HistoryUC *usecase.SearchHistoryUseCase
173+ FavoritesUC *usecase.ManageFavoritesUseCase
174+ FaviconAdapter *adapter.FaviconAdapter
175+ CopyURLUC *usecase.CopyURLUseCase
176+ ShortcutsUC *usecase.SearchShortcutsUseCase
177+ DefaultSearch string
178+ // NormalizeNavigationURL resolves navigation input before search fallback.
179+ // It is injected so local filesystem probing stays outside the domain URL package.
180+ NormalizeNavigationURL func(ctx context.Context, input string) string
181+ InitialBehavior entity.OmniboxInitialBehavior
182+ MostVisitedDays int
183+ SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
184+ UIScale float64 // UI scale for favicon sizing
168185 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
169186 OnNavigate func(ctx context.Context, url string) error
170187 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 {
188205 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
189206
190207 o := &Omnibox{
191- viewMode: ViewModeHistory,
192- selectedIndex: -1,
193- historyUC: cfg.HistoryUC,
194- favoritesUC: cfg.FavoritesUC,
195- faviconAdapter: cfg.FaviconAdapter,
196- copyURLUC: cfg.CopyURLUC,
197- shortcutsUC: cfg.ShortcutsUC,
198- defaultSearch: cfg.DefaultSearch,
199- initialBehavior: cfg.InitialBehavior,
200- mostVisitedDays: cfg.MostVisitedDays,
201- saveInitialBehaviorFn: cfg.SaveInitialBehavior,
202- onToast: cfg.OnToast,
203- onAccentKeyPress: cfg.OnAccentKeyPress,
204- onAccentKeyRelease: cfg.OnAccentKeyRelease,
205- ctx: ctx,
206- uiScale: uiScale,
207- sizeCfg: sizeCfg,
208+ viewMode: ViewModeHistory,
209+ selectedIndex: -1,
210+ historyUC: cfg.HistoryUC,
211+ favoritesUC: cfg.FavoritesUC,
212+ faviconAdapter: cfg.FaviconAdapter,
213+ copyURLUC: cfg.CopyURLUC,
214+ shortcutsUC: cfg.ShortcutsUC,
215+ defaultSearch: cfg.DefaultSearch,
216+ normalizeNavigationURL: cfg.NormalizeNavigationURL,
217+ initialBehavior: cfg.InitialBehavior,
218+ mostVisitedDays: cfg.MostVisitedDays,
219+ saveInitialBehaviorFn: cfg.SaveInitialBehavior,
220+ onToast: cfg.OnToast,
221+ onAccentKeyPress: cfg.OnAccentKeyPress,
222+ onAccentKeyRelease: cfg.OnAccentKeyRelease,
223+ ctx: ctx,
224+ uiScale: uiScale,
225+ sizeCfg: sizeCfg,
208226 }
209227 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
210228 var cb glib.SourceFunc = func(uintptr) bool {
@@ -2380,6 +2398,63 @@ func shouldPreferTypedURLNavigation(entryText string) bool {
23802398 return url.LooksLikeURL(entryText)
23812399 }
23822400
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+
23832458 // toggleFavorite adds or removes the selected item from favorites.
23842459 // In History mode: adds the selected item to favorites
23852460 // In Favorites mode: removes the selected item from favorites
@@ -2410,19 +2485,20 @@ func (o *Omnibox) toggleFavorite() {
24102485 log.Debug().Msg("toggle favorite: empty URL")
24112486 return
24122487 }
2488+ target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2489+ if !ok {
2490+ return
2491+ }
24132492
24142493 go func() {
24152494 ctx := o.ctx
24162495 goLog := logging.FromContext(ctx)
24172496
2418- result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
2497+ result, err := o.favoritesUC.Toggle(ctx, target.url, target.title)
24192498 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")
24212500 if o.onToast != nil {
2422- msg := "Failed to add favorite"
2423- if s.IsFavorite {
2424- msg = "Failed to remove favorite"
2425- }
2501+ msg := favoriteToggleErrorMessage(target.wasFavorite)
24262502 cb := glib.SourceFunc(func(_ uintptr) bool {
24272503 o.onToast(ctx, msg, ToastError)
24282504 return false
@@ -2434,14 +2510,15 @@ func (o *Omnibox) toggleFavorite() {
24342510
24352511 // Update suggestion state
24362512 o.mu.Lock()
2437- if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
2438- o.suggestions[idx].IsFavorite = result.Added
2513+ update := resolveFavoriteToggleResultUpdate(o.suggestions, idx, target.url, result.Added)
2514+ if update.Apply {
2515+ o.suggestions[update.Index].IsFavorite = update.IsFavorite
24392516 }
24402517 o.mu.Unlock()
24412518
24422519 // Update row CSS and show toast on GTK main thread
24432520 cb := glib.SourceFunc(func(_ uintptr) bool {
2444- o.updateRowFavoriteIndicator(idx, s.URL, result.Added)
2521+ o.updateRowFavoriteIndicator(idx, target.url, result.Added)
24452522 if o.onToast != nil {
24462523 o.onToast(ctx, result.Message, ToastSuccess)
24472524 }
@@ -2461,17 +2538,21 @@ func (o *Omnibox) toggleFavorite() {
24612538 log.Debug().Msg("remove favorite: invalid ID")
24622539 return
24632540 }
2541+ target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2542+ if !ok {
2543+ return
2544+ }
24642545 entryText := o.entry.GetText()
24652546
24662547 go func() {
24672548 ctx := o.ctx
24682549 log := logging.FromContext(ctx)
24692550
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")
24712552
2472- err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
2553+ err := o.favoritesUC.Remove(ctx, entity.FavoriteID(target.favoriteID))
24732554 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")
24752556 // Show error toast
24762557 if o.onToast != nil {
24772558 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -2483,7 +2564,7 @@ func (o *Omnibox) toggleFavorite() {
24832564 return
24842565 }
24852566
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")
24872568
24882569 // Refresh favorites list and show toast
24892570 o.mu.RLock()
@@ -2617,7 +2698,7 @@ func (o *Omnibox) buildURL(text string) string {
26172698 if o.shortcutsUC != nil {
26182699 shortcutURLs = o.shortcutsUC.ShortcutURLs()
26192700 }
2620- return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
2701+ return usecase.BuildNavigationURL(o.ctx, text, o.normalizeNavigationURL, shortcutURLs, o.defaultSearch)
26212702 }
26222703
26232704 // 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 (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/gtkutil"
109 "github.com/bnema/dumber/internal/logging"
1110 "github.com/bnema/dumber/internal/ui/component"
1211 "github.com/bnema/dumber/internal/ui/input"
@@ -167,11 +166,7 @@ func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Wi
167166 return nil
168167 }
169168
170- gtkWidget := gtkutil.WidgetFromNativePointer(ptr)
171- if gtkWidget == nil {
172- return nil
173- }
174- widget := c.widgetFactory.WrapWidget(gtkWidget)
169+ widget := c.widgetFactory.WrapNativeWidget(ptr)
175170
176171 // Attach gesture handler for mouse button 8/9 navigation
177172 if widget != nil {
M internal/ui/coordinator/content/lifecycle_test.go +70 -0
@@ -348,6 +348,76 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
348348 assert.Nil(t, c.GetWebView("popup-1"))
349349 }
350350
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+
351421 type syncViewportContextKey struct{}
352422
353423 type syncViewportCapableStub struct {
M internal/ui/coordinator/tab_test.go +5 -5
@@ -26,7 +26,7 @@ func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
2626 secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
2727
2828 gen := counterIDGen()
29- tabsUC := usecase.NewManageTabsUseCase(gen)
29+ tabsUC := usecase.NewManageTabsUseCase(gen, nil)
3030
3131 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
3232 TabsUC: tabsUC,
@@ -124,7 +124,7 @@ func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
124124 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125125
126126 gen := counterIDGen()
127- tabsUC := usecase.NewManageTabsUseCase(gen)
127+ tabsUC := usecase.NewManageTabsUseCase(gen, nil)
128128
129129 // Create TabCoordinator without global tabs (target-driven).
130130 coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
@@ -175,7 +175,7 @@ func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
175175 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176176 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177177
178- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
178+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
179179 coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180180
181181 secondTarget := TabTarget{Tabs: secondTabs}
@@ -222,7 +222,7 @@ func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
222222 secondTabs := entity.NewTabList()
223223 secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224224
225- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
225+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
226226
227227 secondTarget := TabTarget{Tabs: secondTabs}
228228
@@ -260,7 +260,7 @@ func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
260260 targetTabs := entity.NewTabList()
261261 targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262262
263- coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
263+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
264264
265265 target := TabTarget{Tabs: targetTabs}
266266
M internal/ui/coordinator/workspace.go +17 -101
@@ -75,16 +75,6 @@ type stackPaneContext struct {
7575 originalTitle string
7676 }
7777
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-
8878 const (
8979 defaultResizeStepPercent = 0.05
9080 defaultResizeMinPanePercent = 0.1
@@ -173,12 +163,12 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
173163
174164 // Split splits the active pane in the given direction.
175165 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)
177167 }
178168
179169 // SplitWithURL splits the active pane in the given direction and loads initialURL.
180170 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)
182172 }
183173
184174 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
@@ -1261,80 +1251,6 @@ func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
12611251 return closeCtx
12621252 }
12631253
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-
13381254 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
13391255 func (c *WorkspaceCoordinator) doIncrementalStackClose(
13401256 ctx context.Context,
@@ -1544,24 +1460,23 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15441460 return nil
15451461 }
15461462
1547- // Create new pane entity
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
1463+ // Determine if we need to create a new stack or add to existing.
15541464 var stackNode *entity.PaneNode
1465+ var newPane *entity.Pane
1466+ var newPaneID entity.PaneID
15551467 var needsFirstPaneTitleUpdate bool
15561468
15571469 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
15581470 // Already in a stack - use AddToStack use case
15591471 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)
15611473 if err != nil {
15621474 log.Error().Err(err).Msg("failed to add pane to stack via use case")
15631475 return err
15641476 }
1477+ newPane = output.NewPaneNode.Pane
1478+ newPaneID = newPane.ID
1479+ newPane.Title = defaultPaneTitle
15651480 log.Debug().
15661481 Int("stack_size", len(stackNode.Children)).
15671482 Int("insert_index", output.StackIndex).
@@ -1569,28 +1484,29 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15691484 } else if stackCtx.activeNode.IsStacked {
15701485 // Active node is already a stack container - add to it
15711486 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)
15731488 if err != nil {
15741489 log.Error().Err(err).Msg("failed to add pane to stack via use case")
15751490 return err
15761491 }
1492+ newPane = output.NewPaneNode.Pane
1493+ newPaneID = newPane.ID
1494+ newPane.Title = defaultPaneTitle
15771495 log.Debug().
15781496 Int("stack_size", len(stackNode.Children)).
15791497 Int("insert_index", output.StackIndex).
15801498 Msg("added to stack container via use case")
15811499 } else {
1582- // Need to create a new stack - use CreateStack use case
1583- // But CreateStack creates its own pane, so we need a different approach
1584- // Convert to stack first, then the new pane is already created
1585- output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1500+ // Need to create a new stack - use CreateStack use case.
1501+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL)
15861502 if err != nil {
15871503 log.Error().Err(err).Msg("failed to create stack via use case")
15881504 return err
15891505 }
15901506 stackNode = output.StackNode
1591- // CreateStack creates its own new pane, use that instead
15921507 newPane = output.NewPane
15931508 newPaneID = newPane.ID
1509+ newPane.Title = defaultPaneTitle
15941510 needsFirstPaneTitleUpdate = true
15951511
15961512 // Update the original pane's title in the domain
@@ -2119,7 +2035,7 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input con
21192035 stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
21202036
21212037 // 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 {
21232039 // Rollback stack conversion if we created a new stack
21242040 conversionInfo.revert()
21252041 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) {
5959 }
6060 }
6161
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+
62103 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {
63104 closing := testLeafNode("closing")
64105 parent := testSplitNode("parent", closing, nil)
@@ -92,6 +133,22 @@ func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *te
92133 }
93134 }
94135
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+
95152 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {
96153 closing := testLeafNode("closing")
97154 testSplitNode("parent", closing, nil)
M internal/ui/coordinator/workspace_systemview_test.go +3 -3
@@ -19,7 +19,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightSplitsWithTargetURL(t *testin
1919 id := ids[idx]
2020 idx++
2121 return id
22- })
22+ }, nil)
2323
2424 initialPane := entity.NewPane("pane-1")
2525 initialPane.URI = "https://example.com"
@@ -56,7 +56,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
5656 PanesUC: usecase.NewManagePanesUseCase(func() string {
5757 t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")
5858 return "unused"
59- }),
59+ }, nil),
6060 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
6161 return ws, nil
6262 },
@@ -71,7 +71,7 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
7171
7272 func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) {
7373 ctx := context.Background()
74- uc := usecase.NewManagePanesUseCase(func() string { return "unused" })
74+ uc := usecase.NewManagePanesUseCase(func() string { return "unused" }, nil)
7575 left := testLeafNode("pane-1")
7676 left.Pane.URI = "https://example.com"
7777 right := testLeafNode("pane-2")
M internal/ui/deps.go +2 -0
@@ -89,6 +89,8 @@ type Dependencies struct {
8989 SessionSpawner port.SessionSpawner
9090 // FileSystem provides file operations (e.g., for download deduplication).
9191 FileSystem port.FileSystem
92+ // LocalPathResolver resolves user-entered local filesystem paths for navigation.
93+ LocalPathResolver port.LocalPathResolver
9294
9395 // Update management
9496 CheckUpdateUC *usecase.CheckUpdateUseCase
M internal/ui/layout/gtk_widgets.go +7 -0
@@ -827,3 +827,10 @@ func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget {
827827 }
828828 return &gtkWidget{inner: w}
829829 }
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
1389813898 return _c
1389913899 }
1390013900
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+
1390113954 // WrapWidget provides a mock function for the type MockWidgetFactory
1390213955 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {
1390313956 ret := _mock.Called(w)
M internal/ui/layout/widgets.go +1 -0
@@ -241,6 +241,7 @@ type WidgetFactory interface {
241241
242242 // Wrap existing GTK widget
243243 WrapWidget(w *gtk.Widget) Widget
244+ WrapNativeWidget(ptr uintptr) Widget
244245 }
245246
246247 // PaneRenderer creates pane view widgets from domain entities.
M internal/ui/standalone_runtime_test.go +24 -0
@@ -3,11 +3,35 @@ package ui
33 import (
44 "context"
55 "errors"
6+ stdurl "net/url"
7+ "path/filepath"
68 "testing"
79
810 urlutil "github.com/bnema/dumber/internal/domain/url"
911 )
1012
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+
1135 func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) {
1236 browserURL := ""
1337 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