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